Skip to content

Guide + patch: running an OpenAI-compatible tool-calling agent (Hermes) on Foundry Local, and the bugs blocking it (tool_choice=auto, #874) #875

Description

@justinchuby

Running an OpenAI-compatible agent (Hermes) on Foundry Local — end-to-end guide, blocking bugs, and a working client-side patch

This is a detailed, reproducible write-up of wiring a real tool-calling agent — Hermes — to Foundry Local as its LLM backend, doing agentic work (file/terminal tool calls). It documents the exact setup, the FL bugs that block it out of the box, and the client-side patch that makes it work today. The goal is to get these gaps fixed in FL so agents work against /v1/chat/completions with no client-side workaround.

Related issues/PRs already filed:

Environment

  • Foundry Local 0.10.0 (also reproduced on 0.10.1), Linux (Azure Linux 3.0), OpenAI-compatible endpoint on http://127.0.0.1:39839/v1
  • Agent: Hermes CLI (Python), configured with provider: custom pointing at FL
  • Model: qwen2.5-coder-7b-instruct-generic-cpu (a CPU model — see Bug 3)

Part 1 — Setup

1a. Start Foundry Local with a pinned port

foundry server start --port 39839 --idle-timeout 0

--idle-timeout 0 keeps models resident so the agent doesn't pay a reload on every turn. A pinned port is required because the config below hard-codes the base URL. (Note: foundry config set port <N> throws an internal error in 0.10.0, so the port must be pinned on the server start command instead — a separate minor bug.)

1b. Load a tool-capable CPU model

foundry model load qwen2.5-coder-7b-instruct-generic-cpu

1c. Point the agent at FL (Hermes ~/.hermes/config.yaml)

model:
  default: qwen2.5-coder-7b-instruct-generic-cpu
  provider: custom
  base_url: http://127.0.0.1:39839/v1
  max_tokens: 4096          # REQUIRED — see Bug 4

1d. Run the agent

# plain chat
hermes -z 'What is 17 times 3?'

# agentic tool call (file / terminal toolsets)
hermes --yolo -t file     -z 'Create /tmp/test.txt containing hello'
hermes --yolo -t terminal -z 'List the files in the current directory'

With the patch in Part 3 applied, this works end-to-end: the agent plans, FL emits the tool call, the agent executes it, and returns the result.


Part 2 — Bugs that block agentic use out of the box

Bug 1 (blocker) — tool_choice="auto" returns tool calls as text, not structured tool_calls#874

Agents send tool_choice="auto" (the OpenAI default) so the model can either answer or call a tool. Under auto, FL returns the tool call as text inside message.content with an empty tool_calls array and finish_reason="stop". Only tool_choice="required" yields structured tool_calls.

Reproduction (only tool_choice differs), model qwen2.5-coder-7b-instruct-generic-cpu, temperature=0:

curl -s http://127.0.0.1:39839/v1/chat/completions -H "Content-Type: application/json" -d '{
  "model": "qwen2.5-coder-7b-instruct-generic-cpu",
  "messages": [{"role":"user","content":"What is the weather in Paris? Use the tool."}],
  "tools": [{"type":"function","function":{"name":"get_weather","description":"Get weather for a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}],
  "tool_choice": "auto",
  "temperature": 0
}'

tool_choice="auto" (broken for agents):

finish_reason: stop
tool_calls:    []
content:       "```xml\n<tools>\n    {\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}\n</tools>\n```"

tool_choice="required" (structured, as expected):

finish_reason: tool_calls
tool_calls:    [{"index":0,"id":"09xikmhSQ","type":"function","function":{"name":"get_weather","arguments":"{\n  \"city\": \"Paris\"\n}"}}]

Impact: any standard OpenAI agent loop reads message.tool_calls, sees it empty, and treats the tool call as a final text answer — so the agent stalls instead of executing the tool. Forcing tool_choice="required" is not a fix, because it prevents the model from answering directly when no tool is needed.

Requested fix: under auto, run the same server-side tool-call extraction that required already does and populate message.tool_calls with finish_reason="tool_calls".

Bug 2 (related, SDK) — opaque pydantic error on malformed tool call → #864 / PR #872

When a small model emits a malformed tool call (e.g. missing function.name), the Python SDK's complete_chat raises an opaque pydantic ValidationError instead of a FoundryLocalException. Fixed in PR #872 by dropping only the malformed entries and wrapping validation.

Bug 3 — GPU model variants corrupt output on H200/Hopper → #873

Every *-cuda-gpu variant produces deterministic token-doubling/gibberish on H200 (CPU variants are clean), so agents must use CPU models today (slower). Still open; not fixed by 0.10.1.

Bug 4 (client-side, Hermes) — custom-provider default max_tokens overflows FL's context

Hermes' custom provider defaults max_tokens to 65536. FL rejects requests whose max_tokens exceeds the model context (e.g. 32768), surfacing as an incomplete chunked read. Setting model.max_tokens: 4096 (Part 1c) avoids it. Included here so others don't hit it during setup.


Part 3 — The client-side patch that makes it work today

Because Bug 1 lives in FL's closed server, the practical workaround is a client-side adapter: when the provider is the local FL endpoint and tool_calls is empty, parse the tool call out of content and lift it into structured tool_calls. Below is the adapter I added to Hermes; the same idea applies to any OpenAI-compatible agent framework.

Hook point (in the response-normalization path of the agent loop), gated to the local/custom provider so it can never reinterpret a genuine text answer from a real OpenAI/Anthropic backend:

# After normalizing the assistant message, before deciding finish_reason:
if agent.provider == "custom" and not getattr(assistant_message, "tool_calls", None):
    from agent.text_tool_calls import recover_tool_calls_from_text
    if recover_tool_calls_from_text(assistant_message):
        finish_reason = assistant_message.finish_reason  # now "tool_calls"
Adapter module (text_tool_calls.py) — parses the text formats local models emit

It recognises the wrapper-tag JSON forms FL emits (<tools>, <tool_call>, <response>, <function_call>, <tool_calls> — single object or list, arguments or parameters), the Qwen XML function syntax (<function=NAME><parameter=KEY>VALUE</parameter></function>), and bare/fenced JSON with name + arguments. On success it populates tool_calls, strips the call text from content, and flips finish_reason to "tool_calls".

import json, re, uuid
from typing import Any

_WRAPPER_TAGS = ("tool_call", "tool_calls", "tools", "response", "function_call")
_TAG_RE = re.compile(r"<(%s)\b[^>]*>(.*?)</\1>" % "|".join(_WRAPPER_TAGS), re.DOTALL | re.IGNORECASE)
_XML_FUNC_RE = re.compile(r"<function=([^>\s]+)\s*>(.*?)</function>", re.DOTALL | re.IGNORECASE)
_XML_PARAM_RE = re.compile(r"<parameter=([^>\s]+)\s*>(.*?)</parameter>", re.DOTALL | re.IGNORECASE)
_FENCE_RE = re.compile(r"^```[a-zA-Z0-9_-]*\s*|\s*```$")

def _coerce_scalar(v: str) -> Any:
    v = v.strip()
    try: return json.loads(v)
    except Exception: return v

def _strip_fences(t: str) -> str:
    return _FENCE_RE.sub("", t.strip()).strip()

def _normalise_json_call(obj: Any):
    if not isinstance(obj, dict): return None
    name = obj.get("name") or obj.get("function")
    if not isinstance(name, str) or not name.strip(): return None
    args = obj.get("arguments")
    if args is None: args = obj.get("parameters", {})
    args_json = args if isinstance(args, str) else json.dumps(args or {})
    return {"name": name.strip(), "arguments": args_json}

def parse_text_tool_calls(content):
    if not content or ("<" not in content and "{" not in content): return [], []
    calls, spans = [], []
    for fm in _XML_FUNC_RE.finditer(content):
        params = {}
        for pm in _XML_PARAM_RE.finditer(fm.group(2)):
            params[pm.group(1).strip()] = _coerce_scalar(pm.group(2))
        calls.append({"name": fm.group(1).strip(), "arguments": json.dumps(params)})
        spans.append((fm.start(), fm.end()))
    if calls: return calls, spans
    for tm in _TAG_RE.finditer(content):
        try: parsed = json.loads(_strip_fences(tm.group(2)))
        except Exception: continue
        items = parsed if isinstance(parsed, list) else [parsed]
        found = [c for c in (_normalise_json_call(o) for o in items) if c]
        if found:
            calls.extend(found); spans.append((tm.start(), tm.end()))
    if calls: return calls, spans
    for candidate in (content.strip(), _strip_fences(content)):
        try: parsed = json.loads(candidate)
        except Exception: continue
        items = parsed if isinstance(parsed, list) else [parsed]
        found = [c for c in (_normalise_json_call(o) for o in items) if c]
        if found: return found, [(0, len(content))]
    return [], []

def recover_tool_calls_from_text(normalized) -> bool:
    if getattr(normalized, "tool_calls", None): return False
    content = getattr(normalized, "content", None)
    if not isinstance(content, str) or not content.strip(): return False
    calls, spans = parse_text_tool_calls(content)
    if not calls: return False
    # build_tool_call is the agent's helper for an OpenAI-shaped tool call
    normalized.tool_calls = [build_tool_call(id=f"call_{uuid.uuid4().hex[:24]}",
                                             name=c["name"], arguments=c["arguments"])
                             for c in calls]
    normalized.content = None
    normalized.finish_reason = "tool_calls"
    return True

Why gate to the local provider: the recovery must never run against a real OpenAI/OpenRouter/Anthropic response, or a genuine text answer containing JSON/angle-brackets could be misread as a tool call. Gating by provider makes it a safe, targeted shim.


Verified result

With FL running the CPU model and the adapter in place, the agent completes a full tool-calling turn:

$ hermes --yolo -t file -z 'Create a file at /tmp/proof.txt containing exactly: FL_TOOLCALL_OK'
The file /tmp/proof.txt has been successfully created with the text "FL_TOOLCALL_OK".
$ cat /tmp/proof.txt
FL_TOOLCALL_OK

Plain chat works too (17 * 351).

Ask

The adapter is only necessary because of Bug 1 (#874). If FL surfaces structured tool_calls under tool_choice="auto" (server-side), any OpenAI-compatible agent — Hermes, LangChain, the OpenAI SDK, custom loops — works against Foundry Local with zero client-side patching. Happy to help test a fix on the same setup.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions