Skip to content

feat: tool calling and in-JVM embeddings (0.3.0) - #106

Merged
bsbodden merged 2 commits into
mainfrom
feat/tool-calling-and-embeddings
Aug 5, 2026
Merged

feat: tool calling and in-JVM embeddings (0.3.0)#106
bsbodden merged 2 commits into
mainfrom
feat/tool-calling-and-embeddings

Conversation

@bsbodden

@bsbodden bsbodden commented Aug 5, 2026

Copy link
Copy Markdown
Member

Adds two capabilities the runtime lacked, and prepares the 0.3.0 release.

Tool calling

ToolSpec and ToolCall describe declarations and invocations while keeping argument text opaque — both target frameworks want raw JSON strings, and locating an argument's extent needs only brace matching, so models-api stays dependency-free.

ToolSyntax records how each family expresses calls, read from its published chat template, and drives both rendering and recovery from one descriptor. llama.cpp reached the same conclusion from the other direction, collapsing ~20 hand-written family parsers into a small inferred taxonomy.

Only verified families are populated. Gemma 4 and MiniCPM5 have real formats, but encode arguments as tagged pairs carrying no type information — <param name="n">1</param> cannot become JSON without the declared schema. They report supportsTools() but not canParseToolCalls(), and adapters gate on the latter, so the refusal reason is "this runtime cannot decode tagged arguments" rather than the false "this model has no tool format".

The trust boundary is enforced, not asserted

Tool results are attacker-influenced in a way ordinary chat text is not. Delimiters render as CONTROL segments and results as TEXT; because the tokenizer consults the special-token table only inside CONTROL, a result spelling out control markers cannot close a span or open a turn.

Verified against the real Qwen3 vocabulary:

Token id as CONTROL as TEXT
<tool_call> 151657 1 token 4 tokens
<|im_start|> 151644 1 token 6 tokens

The secure path and the training-faithful path are the same path.

Embeddings

An embedding is the generative pass with its head removed. Head replaces the boolean that conflated "project to vocabulary" with "is this activation needed at all" — prefill legitimately skips most of the final layer when the activation is discarded, but embedding needs precisely that activation. Both pruning shortcuts had to be excluded, including one that skips the last layer's FFN; a second boolean would have hidden it.

EmbeddingBackend moves to models-api because it is a contract. Leaving it in models-embedding would have forced every backend implementing it to depend on vectors-db, inverting the models -> vectors tier direction.

Sampler

Full-vocabulary sort replaced with a bounded-heap top-k selection: 19.251 ms -> 0.848 ms per sampled token at a 151,936-token vocabulary. Ties still prefer the lower token id, so seeded output is unchanged — a 500-trial oracle test pins equivalence against the previous implementation.

Verification

894 tests, 0 failures. clean spotlessCheck build complianceCheck green, including verifyArchitectureBoundaries and verifyReleaseMetadata.

13 of those run against real Qwen3-Embedding-0.6B weights (no mocks): related text lands closer than unrelated with a >0.25 margin, paraphrased support queries separate from off-topic by >0.20, and both framework adapters produce byte-identical vectors from one backend — so an application can move between them without re-indexing.

Both integration suites are wired into model-integration.yml.

Breaking change

EmbeddingBackend moved from com.integrallis.models.embedding to com.integrallis.models.api. No published artifact implemented it.

🤖 Generated with Claude Code

Adds two capabilities the runtime lacked, plus the release metadata for 0.3.0.

Tool calling. ToolSpec and ToolCall describe declarations and invocations while
keeping argument text opaque, so models-api stays dependency-free: both target
frameworks want raw JSON strings, and locating an argument's extent needs only
brace matching. ToolSyntax records how each family expresses calls, taken from
its published chat template, and drives rendering and recovery from one
descriptor. llama.cpp reached the same conclusion from the other direction,
collapsing roughly twenty hand-written family parsers into a small taxonomy.

Only verified families are populated. Gemma 4 and MiniCPM5 have real formats but
encode arguments as tagged pairs that carry no type information, so producing
JSON from them needs the declared schemas; they report supportsTools() but not
canParseToolCalls(), and adapters gate on the latter.

Tool results are attacker-influenced in a way ordinary chat text is not, so
delimiters render as CONTROL segments and results as TEXT. Because the tokenizer
recognises registered tokens only inside CONTROL, a result spelling out control
markers cannot forge a turn. Verified against the real Qwen3 vocabulary, where
<tool_call> is one token (151657) as CONTROL and four as TEXT: the secure path
and the training-faithful path are the same path.

Embeddings. An embedding is the generative pass with its head removed, so
Head replaces the boolean that conflated "project to vocabulary" with "is this
activation needed at all". Prefill legitimately skips most of the final layer
when the activation is discarded; embedding needs precisely that activation.
Both pruning shortcuts had to be excluded, including one that skips the last
layer's FFN, which a second boolean would have hidden.

EmbeddingBackend moves to models-api because it is a contract. Leaving it in
models-embedding would have forced every backend implementing it to depend on
vectors-db, inverting the models -> vectors tier direction.

Sampler replaces a full-vocabulary sort with a bounded-heap top-k selection,
19.251 ms to 0.848 ms per sampled token at a 151,936-token vocabulary. Ties
still prefer the lower token id, so seeded output is unchanged; a 500-trial
oracle test pins equivalence against the previous implementation.

Adds integration coverage against real Qwen3-Embedding-0.6B weights: related
text lands closer than unrelated with a >0.25 margin, and both framework
adapters produce byte-identical vectors from one backend.

BREAKING CHANGE: EmbeddingBackend moved from com.integrallis.models.embedding to
com.integrallis.models.api. No published artifact implemented it.
…ibility

JsonRawSchema postdates LangChain4j 1.0.0, which this module still compiles
against, so naming it directly broke the oldest supported version in the
compatibility matrix. Detect it structurally and read its payload reflectively;
the test builds one the same way and skips where the type is absent.

Verified against the exact CI invocations for LangChain4j 1.0.0, 1.13.1 and
1.17.2, and Spring AI 1.1.4, 1.1.8 and 2.0.0.
@bsbodden
bsbodden merged commit 1b4dee0 into main Aug 5, 2026
31 checks passed
@bsbodden
bsbodden deleted the feat/tool-calling-and-embeddings branch August 5, 2026 20:17
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