Skip to content

Support automatic Qwen tool calls for coding workflows - #1104

Open
Baiju Meswani (baijumeswani) wants to merge 18 commits into
baijumeswani/apply-patch-toolfrom
baijumeswani/qwen-auto-tools
Open

Baiju Meswani (baijumeswani) wants to merge 18 commits into
baijumeswani/apply-patch-toolfrom
baijumeswani/qwen-auto-tools

Conversation

@baijumeswani

Copy link
Copy Markdown
Collaborator

What this changes

This PR makes Foundry Local work better with coding-style tool use on qualified Qwen chat models.

Before this change, Qwen could emit native XML tool calls, but Foundry Local treated that output as plain assistant text. That meant clients like GitHub Copilot CLI and Toolkit could see text that looked like a tool call, but they could not actually execute the tool through the normal tool-calling path.

This PR teaches Foundry Local to recognize the exact Qwen XML tool-call format we qualified, convert it into normal structured tool calls, and keep tool results associated with the correct call when the model emits multiple calls in one turn.

Why this is needed

The coding scenario depends on reliable tool use.

Simple examples:

  • The model should be able to ask to view a file and get the file content back.
  • The model should be able to ask to run bash for a focused test and get the test output back.
  • The model should be able to emit two tool calls in one turn and still receive the right result for each call.
  • The model should be able to use raw apply_patch when that path is explicitly supported, without breaking ordinary structured tool calls.

Without this change, Qwen-native tool output leaks back to the client as visible XML, or parallel tool results can be rendered back to the model in the wrong order for the qualified Qwen template.

Examples

Before

The model emits text like:

<tool_call>
<function=view>
<parameter=path>
src/main.cc
</parameter>
</function>
</tool_call>

Foundry Local returns that as assistant text instead of a structured tool call, so the client cannot run the tool through the normal tool-calling loop.

After

The same output is decoded as a normal tool call:

{
  "name": "view",
  "arguments": {
    "path": "src/main.cc"
  }
}

The client receives an actual tool-call item, executes it, and sends back the tool result through the existing tool-result path.

Multiple calls in one turn

If the model emits:

<tool_call>
<function=view>
<parameter=path>
src/a.cc
</parameter>
</function>
</tool_call>
<tool_call>
<function=view>
<parameter=path>
src/b.cc
</parameter>
</function>
</tool_call>

Foundry Local preserves that as two adjacent structured calls in source order.

If the tool results come back in a different completion order, Foundry Local still renders the results back to the qualified Qwen template in the assistant-call order the model expects.

Raw apply_patch still works

If output is already claimed by the raw-envelope path for apply_patch, that path keeps ownership of the bytes.

This PR does not replace or weaken the raw-tool behavior added for the coding harness. It composes with it.

Scope

Included:

  • internal detection of the qualified Qwen model type;
  • production-template probing so the behavior only enables for the exact qualified dialect;
  • narrow decoding of the qualified Qwen XML tool-call format;
  • schema-aware argument conversion into Foundry Local's existing structured tool-call representation;
  • support for one contiguous batch of adjacent tool calls in a single assistant turn;
  • positional tool-result projection for the qualified Qwen template when multiple adjacent calls are followed by their results;
  • graceful fallback to visible text for malformed, incomplete, fenced, oversized, unsupported, or undeclared XML tool-call candidates.

Not included:

  • arbitrary XML frameworks;
  • new public API for model dialects;
  • patch execution itself;
  • cancellation/resource-safety work;
  • prefix caching;
  • model packaging or dependency pinning.

Design intent

Keep the change narrow and fail closed.

This PR only turns the behavior on for the exact qualified Qwen shape:

  • exact model type: qwen3_5_text;
  • matching production template behavior for tool-call rendering;
  • matching production template behavior for positional tool-result consumption.

If those checks do not match, Foundry Local keeps the existing behavior.

This avoids introducing a broad XML parser, a generic dialect system, or behavior changes for unrelated models.

Behavior details

Tool-call decoding

The decoder accepts only the exact qualified form:

<tool_call>
<function=tool_name>
<parameter=parameter_name>
value
</parameter>
</function>
</tool_call>

It converts the decoded payload into the existing provider-neutral parsed tool-call shape.

Argument conversion follows the declared tool schema:

  • declared string parameters stay strings;
  • declared numeric, boolean, null, array, and object parameters must parse as compatible JSON;
  • unsupported or ambiguous schema shapes fall back to visible text.

Fallback behavior

These cases remain visible text and are not turned into tool calls:

  • malformed XML;
  • incomplete XML;
  • fenced examples;
  • undeclared tool names;
  • duplicate parameters;
  • unknown parameters;
  • missing required parameters;
  • unsupported schema shapes;
  • oversized candidates;
  • alternate tags or unsupported XML constructs.

Multi-call result ordering

For the qualified Qwen template, consecutive tool results are interpreted positionally.

This PR reorders only the copied render group that is sent back through the prompt-building path. It does not mutate the canonical transcript or public tool-call IDs.

If the result history is ambiguous, Foundry Local rejects the request before generation instead of guessing.

User-visible effect

For qualified Qwen coding models, automatic tool use becomes much more reliable.

Examples of workflows this enables:

  1. Read a file with view.
  2. Search code with rg.
  3. Run a focused test with bash.
  4. Continue after tool results without leaking XML back to the user.

That is the core behavior needed for coding harnesses that rely on automatic tool calling.

Validation summary

The change was qualified against targeted unit and integration coverage, including:

  • ordinary text responses;
  • one-call and two-call automatic tool use;
  • mixed string and structured arguments;
  • streaming and non-streaming behavior;
  • raw apply_patch coexistence;
  • malformed/incomplete/fenced fallback;
  • ambiguous result-history rejection;
  • long-context continuation scenarios.

Real Qwen qualification showed the expected improvement for coding-style tool loops such as reading files, running shell commands, and continuing after tool results.

@baijumeswani
Baiju Meswani (baijumeswani) added this pull request to stack #1086 September 13, 2026 22:47
@vercel

vercel Bot commented Sep 13, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
foundry-local Ready Ready Preview Sep 16, 2026 12:34am UTC

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Boundary handling, duplicate-schema ambiguity, required-tool enforcement, and streaming performance issues remain unresolved.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds qualified Qwen XML tool-call decoding and positional result ordering across chat backends.

Changes:

  • Detects compatible Qwen model templates and enables native XML decoding.
  • Adds schema-aware, streaming-safe tool-call parsing and result projection.
  • Expands ABI, fallback, streaming, and integration coverage.
File summaries
File Description
sdk_v2/cpp/CMakeLists.txt Builds the Qwen decoder.
sdk_v2/cpp/test/CMakeLists.txt Registers capability tests.
tool_definition_abi_test.cc Covers legacy serialized tools.
tool_call_utils_test.cc Tests malformed metadata handling.
tool_call_stream_accumulator_test.cc Covers Qwen streaming and fallbacks.
grammar_test.cc Tests malformed schemas.
genai_model_instance_test.cc Tests capability probing.
chat_transcript_test.cc Tests positional result projection.
chat_session_test.cc Adds end-to-end Qwen coverage.
tool_call_utils.cc Safely reads advertised names.
tool_call_stream_accumulator.h Adds payload-parser streaming mode.
tool_call_payload_parser.h Defines the parser contract.
qwen_xml_tool_call_decoder.h Declares Qwen decoder creation.
qwen_xml_tool_call_decoder.cc Implements schema-aware XML decoding.
markdown_fence_tracker.h Prevents fenced examples becoming calls.
grammar.cc Hardens schema generation.
genai_model_instance.h Exposes detected capabilities.
genai_model_instance.cc Probes model type and templates.
onnx_engine_chat_stream.h Accepts prepared messages.
onnx_engine_chat_stream.cc Renders projected Engine prompts.
onnx_chat_generator.h Accepts prepared messages.
onnx_chat_generator.cc Renders projected Generator prompts.
chat_template.h Defines positional projection APIs.
chat_template.cc Implements result reordering.
chat_session.h Adds parser and preparer integration.
chat_session.cc Routes Qwen calls through production paths.
chat_generator.h Updates the backend interface.
Review details

Suppressed comments (1)

sdk_v2/cpp/src/inferencing/generative/toolcalling/qwen_xml_tool_call_decoder.cc:173

  • This parameterless-schema branch also ignores duplicate insertion failure. Two declarations with the same name and empty/object-with-no-properties schemas therefore leave one declaration valid, even though the selected schema is ambiguous. Use the same duplicate-invalidating insertion path for this branch.
    if (parameters.empty() ||
        (parameter_type.has_value() && *parameter_type == "object" && has_no_properties &&
         has_no_required_parameters)) {
      schema.valid =
          !parameters.contains("properties") || parameters["properties"].is_object();
      schemas.emplace(name, std::move(schema));
      continue;
  • Files reviewed: 27/27 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread sdk_v2/cpp/src/inferencing/generative/chat/chat_session.cc Outdated
Comment thread sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h Outdated
Comment thread sdk_v2/cpp/src/inferencing/generative/toolcalling/tool_call_stream_accumulator.h Outdated
Comment thread sdk_v2/cpp/test/internal_api/chat/chat_session_test.cc Outdated
Comment thread sdk_v2/cpp/test/internal_api/toolcalling/tool_call_stream_accumulator_test.cc Outdated
Baiju Meswani (baijumeswani) added a commit that referenced this pull request Sep 16, 2026
## Summary

Add provider-layer support for text custom tools to the
OpenAI-compatible Chat Completions and Responses APIs.

A function tool receives a structured JSON object. A text custom tool
receives one free-form string payload. This PR
preserves that distinction across request parsing, prompt construction,
generated output, streaming, storage, replay,
and continuation.

## Supported wire formats

### Chat Completions

```json
{
  "type": "custom",
  "custom": {
    "name": "edit",
    "description": "Replace file content",
    "format": {
      "type": "text"
    }
  }
}
```

A generated call is returned as:

```json
{
  "id": "call_abc123",
  "type": "custom",
  "custom": {
    "name": "edit",
    "input": "replacement text"
  }
}
```

### Responses

```json
{
  "type": "custom",
  "name": "edit",
  "description": "Replace file content",
  "format": {
    "type": "text"
  }
}
```

A completed call is returned as:

```json
{
  "id": "ctc_abc123",
  "type": "custom_tool_call",
  "call_id": "call_abc123",
  "name": "edit",
  "input": "replacement text",
  "status": "completed"
}
```

The caller can continue the Responses conversation with a correlated
result:

```json
{
  "previous_response_id": "resp_abc123",
  "input": [
    {
      "type": "custom_tool_call_output",
      "call_id": "call_abc123",
      "output": "Edit applied"
    }
  ]
}
```

## Behavior

- Supports mixed function and text custom tools in one request.
- Supports automatic, required, and explicitly forced choices at the
provider-contract level.
- Preserves the effective tool name and kind in an immutable request
snapshot.
- Uses an internal synthesized `{"input": "<text>"}` schema so existing
model templates can represent a text tool.
- Returns the original text payload through the provider's custom-tool
shape.
- Preserves newlines, indentation, tabs, trailing spaces, quotes,
Unicode, empty input, and JSON-looking text.
- Streams one stable call ID through call creation, payload deltas,
completion, storage, and later result correlation.
- Keeps parallel calls on one assistant turn and validates results
against their call IDs.
- Rebuilds stored custom calls and results using the same canonical
prompt representation as a live continuation.

## Validation

The provider adapters reject:

- unknown tool types;
- missing or empty tool names;
- duplicate names;
- a forced choice for an undeclared tool or the wrong tool kind;
- function-only fields on a custom tool;
- non-text custom formats or unsupported text-format constraints;
- `strict: true` function tools, because strict schema enforcement is
not implemented;
- malformed custom calls/results and unmatched call IDs.

Unsupported constraints are rejected rather than silently ignored.

## Scope

This PR implements provider transport and lifecycle support for generic
text custom tools. It does **not** implement:

- the stock GitHub Copilot `apply_patch` Lark grammar or
marker-delimited raw-envelope recognition; that is #1088;
- model-specific automatic Qwen XML tool-call decoding; that is #1104;
- reordering parallel tool results for positional model templates.

Accordingly, this PR supplies the provider foundation needed by
patch-style tools, but does not by itself make the
stock Copilot `apply_patch` declaration executable.
Decode qualified Qwen XML function-call output under automatic tool choice and project positional tool results into model call order without mutating canonical history.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Accept Qwen XML calls for canonical custom-tool schemas and unwrap their free-form input through the existing custom-tool path. Preserve raw-envelope support and reject ambiguous framing or noncanonical custom schemas.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Support Copilot's constrained glob.paths union, preserve source comparison operators in exact string arguments, and reject nested Qwen framing without broadening automatic XML decoding.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Reject schema assertions and nested structures the XML decoder cannot validate, keeping automatic calls fail-closed. Remove an unused projection wrapper and use protocol-neutral selected-payload finalization names.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Reject unsupported root parameter assertions and cap recursive schema/value validation while preserving the qualified fail-closed interpretation of structured union prefixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Reject malformed tool-only schemas before generation, invalidate duplicate Qwen declarations consistently, and make selected-payload streaming bounded and amortized linear at the 64 KiB boundary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Preserve exact-limit calls across whitespace-prefixed ordinary output, structurally validate required guidance schemas, reject duplicate legacy guidance declarations, and extend parameterless duplicate coverage.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Validate nested guidance schema types and cover exact-limit whitespace adjacency, partial-marker EOS, duplicate guidance names, and parameterless duplicate decoder branches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 6883972e-cc0b-4076-a3a5-86106e7a1133
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.

2 participants