erenyanic/finance-tool-agent-space
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:
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:
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:
- 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.
- 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 returnedHTTP 200with an empty completion andno_response; requests with a truncated or absent few-shot returned a clean402. Pure credit exhaustion should have produced402everywhere. So either credits died first andno_responseis 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 asdegrade. 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 asrole: "tool". A template with no tool slot either errors or silently drops that role. The user-turn form also keepsuser → assistant → user → assistantalternation 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; acceptstool/parametersaliases 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: trueplustoolsfragments 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.
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.
python3 -m http.server 8000 # then open http://localhost:8000Tests (no network, no inference credits — the router and both data APIs are mocked):
node --test tests/*.mjsFiles
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 loopprotocol.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.
