CoolFace
Apppublic

erenyanic/finance-tool-agent-space

sourceHugging Facemitupdated 2mo agoView on Hugging Face
0likes
App README

Finance Tool-Agent

A tool-calling agent for live crypto and FX data, built so that models with no native function-calling support can still call tools.

Live demo: https://huggingface.co/spaces/Erenyanic/finance-tool-agent-space

The idea

Most tool-calling demos pass a tools array to a provider that already supports it, and the provider does the work. That teaches you nothing about how tool calling functions — and it excludes every small or older model whose chat template has no tool slot.

This project implements the mechanism itself, in two interchangeable modes over one shared set of JSON Schema definitions:

ModeHow a call is producedWorks on
Nativetools array → provider returns message.tool_callsonly models with native support
Promptedschemas injected into the system prompt → we parse <tool_call> blocks out of plain textany chat model
Autonative first, falls back to prompted the moment the provider refuses or ignores toolsany chat model

The prompted path is the substance of the project. It is what lets google/gemma-2-2b-it — which has no tool support whatsoever — call a tool.

Measured model behaviour

Verified against HF Inference Providers, with nativeTools read from each model's actual tokenizer_config.json chat template:

ModelNative toolsNotes
google/gemma-2-2b-it❌ noneTemplate also raises on a system role and demands strictly alternating turns
microsoft/Phi-3-mini-4k-instruct❌ nonePrompted mode only
mistralai/Mistral-7B-Instruct-v0.2❌ nonePrompted mode only
Qwen/Qwen3.5-2BPrompted protocol succeeds only sometimes at this size
Qwen/Qwen3.5-4BPrompted protocol verified working end-to-end
Qwen/Qwen2.5-3B-InstructControl group

Reliability is not claimed to be total. A 2B model will sometimes emit the reasoning line and forget the call. The app surfaces that honestly rather than hiding it.

An observed failure, and what was done about it

Running the real loop against Qwen/Qwen3.5-2B produced this, twice:

[Turn 1] FINAL: I need the live price of Bitcoin.

The model imitated the leading sentence of the few-shot example and emitted EOS before ever reaching the tool call. Two changes address it:

  1. 1.Few-shot examples are now real message turns, not dialogue text inside the system prompt, and the demonstrated assistant turns contain nothing but the call. A prose line in the example is what the model was copying.
  2. 2.A nudge retry. If no call is emitted and no tool has run yet, the loop re-prompts once with an explicit format reminder, and shows the nudge in the trace.
Status — read this before judging the prompted path. The nudge retry, the response-parsing fix and the degrade path are all covered by offline tests. The few-shot restructuring is not confirmed against the live model: the account's Inference credits ran out immediately after the failure was reproduced, so the corrected prompt has never had a successful live run. An isolation sweep afterwards was ambiguous in a way worth recording. Requests carrying the full 10-message few-shot returned HTTP 200 with an empty completion and no_response; requests with a truncated or absent few-shot returned a clean 402. Pure credit exhaustion should have produced 402 everywhere. So either credits died first and no_response is how the provider reports that on a larger payload, or the 10-message payload itself trips featherless — the two could not be separated without credits. Rather than bet on one reading, the loop sheds the few-shot examples and retries on a smaller payload whenever a provider-level failure occurs, and shows it in the trace as degrade. If the examples are the problem, the run still completes on the configuration that was observed returning real content.

A provider quirk worth knowing

featherless-ai can return HTTP 200 whose body is a valid empty completion concatenated with a separate error object:

{"choices":[{"message":{"role":"assistant","content":""}}]}{"error":{"message":"No successful response received..."}}

A plain JSON.parse throws on that, and treating the failure as null produced a misleading "provider returned no message". The reader now salvages the first balanced object and surfaces any trailing error.

What the design had to account for

Each of these is a response to observed behaviour, not a precaution:

  • Stop sequence `</tool_call>`. Without it, small models emit a call and then hallucinate its result in the same completion.
  • Results are fed back as a `user` turn prefixed TOOL_RESULT, never as role: "tool". A template with no tool slot either errors or silently drops that role. The user-turn form also keeps user → assistant → user → assistant alternation valid, which gemma-2 requires.
  • Reasoning blocks are stripped. Qwen3.5 emits chain-of-thought ended by </think> — frequently with no opening tag, so a paired regex misses it and the reasoning leaks into the answer.
  • The parser is deliberately tolerant. It accepts tagged, fenced and bare objects; repairs single quotes, trailing commas, unquoted keys and Python None/True; accepts tool/parameters aliases and arguments delivered as a JSON string; and deduplicates a call emitted twice in two formats.
  • Arguments are coerced to the schema. Models routinely send "days": "30". Untreated, arithmetic becomes string concatenation.
  • No streaming. stream: true plus tools fragments tool-call deltas inconsistently across providers.
  • Tool failures return `{ error: ... }` as data, so the model can retry with corrected arguments or explain the problem, instead of the loop dying.

Tools

Five tools over two free, keyless, CORS-enabled APIs.

ToolArgumentsSource
search_coinqueryCoinGecko
get_pricecoin_id, vs_currency?CoinGecko
get_market_chartcoin_id, days?, vs_currency?CoinGecko
get_coin_infocoin_idCoinGecko
convert_currencyamount, from_currency, to_currencyopen.er-api.com

Every tool returns a small hand-picked object. Raw payloads are never forwarded: a 169-point price series or a 2 kB description would swamp a 2B context window.

Example trace

Asking "Is Ethereum pricier than Solana, and what is Ethereum worth in Turkish lira?" produces multi-turn chaining, shown live in the UI:

[Turn 1]
-> get_price(coin_id="ethereum")
<- {"coin_id":"ethereum","vs_currency":"USD","price":1919.55,"change_24h_pct":0.77}
-> get_price(coin_id="solana")
<- {"coin_id":"solana","vs_currency":"USD","price":138.2,"change_24h_pct":-0.4}

[Turn 2]
-> convert_currency(amount=1919.55, from_currency="USD", to_currency="TRY")
<- {"result":81580.88,"rate":42.5,"to_currency":"TRY"}

[Turn 3] Final response
Ethereum is far pricier than Solana ($1,919.55 vs $138.20). In Turkish lira,
Ethereum is worth about ₺81,580.88.

Running it

Nothing to build or install. It is a static page of ES modules.

bash
python3 -m http.server 8000   # then open http://localhost:8000

Tests (no network, no inference credits — the router and both data APIs are mocked):

bash
node --test tests/*.mjs

Files

index.html   UI shell and styles
app.js       UI wiring, model/mode selection, live trace rendering
agent.js     the agent loop: native mode, prompted mode, auto fallback
protocol.js  prompt assembly, tolerant parser, reasoning stripping, template shims
tools.js     JSON Schema definitions + implementations
tests/       46 offline tests over the parser and the loop

protocol.js and tools.js carry the substance; agent.js is the loop that a framework would otherwise hide. No LangChain or LangGraph: the assignment requires exposing every tool call and reasoning step, which is precisely what such a framework abstracts away.

Tokens and quota

Inference runs against your Hugging Face account, using your own quota. Sign in with the Hugging Face button (OAuth, inference-api scope) or paste a token. This Space is static and has no backend, so nothing is stored server-side; a pasted token is kept in sessionStorage only.

Note on the deliverable format

The brief asks for app.py and requirements.txt with Gradio or Streamlit. Hugging Face now returns `402 Payment Required` when creating a Gradio Space on free cpu-basic:

Static Spaces are free for everyone, but hosting Gradio and Docker Spaces on free cpu-basic requires a PRO subscription.

Only sdk: static remains free, and a static Space has no Python runtime. The app is therefore client-side ES modules rather than Python — app.js in place of app.py, and no requirements.txt because there are no dependencies to install. Every functional requirement of the brief is met: a public keyless API, JSON Schema tool definitions, model-driven tool selection, multi-turn chaining, a visible reasoning and execution trace, and a live deployed UI.

Licence

MIT.