2026-07-01 06:53:22 +00:00
package llamacpp
import (
"bufio"
2026-07-12 23:14:15 +00:00
"bytes"
2026-07-01 06:53:22 +00:00
"context"
"encoding/json"
"fmt"
"io"
"iter"
2026-07-12 23:14:15 +00:00
"net/http"
2026-07-01 06:53:22 +00:00
"strings"
2026-07-09 06:33:20 +00:00
"time"
2026-07-01 06:53:22 +00:00
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
)
2026-07-09 06:33:20 +00:00
// defaultMaxTokens is used when neither the Config nor the per-request
// CompletionRequest specify one, so requests never go out with an
// unbounded/zero max_tokens.
const defaultMaxTokens = 4096
// defaultContextWindow is reported by Capabilities() when Config.ContextWindow is unset.
const defaultContextWindow = 32768
2026-07-15 21:50:45 +00:00
// reasoningCharsPerToken converts MaxThinkingTokens into a character budget
// for client-side enforcement (token counts aren't available per SSE delta).
// ~4 chars/token is deliberately generous for mixed Spanish/English/code, so
// the cut only ever fires later than the configured token budget, not before.
const reasoningCharsPerToken = 4
2026-07-01 06:53:22 +00:00
// Config holds the settings needed to create a llama.cpp client.
type Config struct {
2026-07-12 23:14:15 +00:00
BaseURL string // defaults to http://localhost:8080/v1
Model string
Timeout int // request timeout in seconds (0 = default, no timeout)
ContextWindow int // model's context window in tokens (0 = defaultContextWindow)
MaxTokens int // default max_tokens (0 = defaultMaxTokens)
TopK int // top-k sampling (0 = model/server default)
TopP float32 // nucleus sampling (0 = model/server default)
Temperature float32
MinP float32 // min-p sampling (llama.cpp extension)
2026-07-09 06:33:20 +00:00
PresencePenalty float32
RepetitionPenalty float32 // sent as the server's `repeat_penalty` field
2026-07-15 21:50:45 +00:00
MaxThinkingTokens int // cap on reasoning tokens, enforced client-side during Stream (llama.cpp ignores the JSON field, so the stream is cut and the request aborted once the estimate is exceeded); 0 = unlimited
2026-07-01 06:53:22 +00:00
}
// Client implements llm.LLMClient for llama.cpp.
type Client struct {
baseURL string
2026-07-09 06:33:20 +00:00
model string
2026-07-01 06:53:22 +00:00
http * http . Client
2026-07-09 06:33:20 +00:00
contextWindow int
maxTokens int
topK int
topP float32
temperature float32
minP float32
presencePenalty float32
repetitionPenalty float32
maxThinkingTokens int
2026-07-01 06:53:22 +00:00
}
// New returns a new llama.cpp client.
func New ( cfg Config ) ( * Client , error ) {
baseURL := cfg . BaseURL
if baseURL == "" {
baseURL = "http://localhost:8080/v1"
}
2026-07-09 06:33:20 +00:00
maxTokens := cfg . MaxTokens
if maxTokens == 0 {
maxTokens = defaultMaxTokens
}
contextWindow := cfg . ContextWindow
if contextWindow == 0 {
contextWindow = defaultContextWindow
}
httpClient := http . DefaultClient
if cfg . Timeout > 0 {
httpClient = & http . Client { Timeout : time . Duration ( cfg . Timeout ) * time . Second }
}
2026-07-01 06:53:22 +00:00
return & Client {
2026-07-09 06:33:20 +00:00
baseURL : baseURL ,
model : cfg . Model ,
http : httpClient ,
contextWindow : contextWindow ,
maxTokens : maxTokens ,
topK : cfg . TopK ,
topP : cfg . TopP ,
temperature : cfg . Temperature ,
minP : cfg . MinP ,
presencePenalty : cfg . PresencePenalty ,
repetitionPenalty : cfg . RepetitionPenalty ,
maxThinkingTokens : cfg . MaxThinkingTokens ,
2026-07-01 06:53:22 +00:00
} , nil
}
func ( c * Client ) Generate ( ctx context . Context , req llm . CompletionRequest ) ( llm . CompletionResponse , error ) {
endpoint := c . baseURL + "/chat/completions"
payload , err := c . buildRequest ( req , false )
if err != nil {
return llm . CompletionResponse { } , fmt . Errorf ( "building request: %w" , err )
}
httpReq , err := http . NewRequestWithContext ( ctx , http . MethodPost , endpoint , payload )
if err != nil {
return llm . CompletionResponse { } , fmt . Errorf ( "creating request: %w" , err )
}
httpReq . Header . Set ( "Content-Type" , "application/json" )
resp , err := c . http . Do ( httpReq )
if err != nil {
return llm . CompletionResponse { } , fmt . Errorf ( "request failed: %w" , err )
}
defer resp . Body . Close ( )
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
body , _ := io . ReadAll ( resp . Body )
return llm . CompletionResponse { } , fmt . Errorf ( "API error %d: %s" , resp . StatusCode , string ( body ) )
}
var apiResp llamaChatResponse
if err := json . NewDecoder ( resp . Body ) . Decode ( & apiResp ) ; err != nil {
return llm . CompletionResponse { } , fmt . Errorf ( "decoding response: %w" , err )
}
2026-07-12 23:14:15 +00:00
return c . toResponse ( apiResp )
2026-07-01 06:53:22 +00:00
}
func ( c * Client ) Stream ( ctx context . Context , req llm . CompletionRequest ) iter . Seq2 [ llm . StreamChunk , error ] {
return func ( yield func ( llm . StreamChunk , error ) bool ) {
endpoint := c . baseURL + "/chat/completions"
payload , err := c . buildRequest ( req , true )
if err != nil {
yield ( llm . StreamChunk { } , fmt . Errorf ( "building request: %w" , err ) )
return
}
httpReq , err := http . NewRequestWithContext ( ctx , http . MethodPost , endpoint , payload )
if err != nil {
yield ( llm . StreamChunk { } , fmt . Errorf ( "creating request: %w" , err ) )
return
}
httpReq . Header . Set ( "Content-Type" , "application/json" )
resp , err := c . http . Do ( httpReq )
if err != nil {
yield ( llm . StreamChunk { } , fmt . Errorf ( "request failed: %w" , err ) )
return
}
defer resp . Body . Close ( )
if resp . StatusCode < 200 || resp . StatusCode >= 300 {
body , _ := io . ReadAll ( resp . Body )
yield ( llm . StreamChunk { } , fmt . Errorf ( "API error %d: %s" , resp . StatusCode , string ( body ) ) )
return
}
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
// toolCallAccum buffers one tool call's fragments as they stream in:
// the OpenAI-compatible SSE format sends the id/name in the first
// delta for a given tool-call index and the (potentially large)
// arguments JSON in pieces across many subsequent deltas, so it
// can't be handed to a tool handler until it's fully assembled.
type toolCallAccum struct {
id string
name string
args strings . Builder
}
toolCallFrags := map [ int ] * toolCallAccum { }
var toolCallOrder [ ] int
// flushToolCalls assembles the buffered fragments into complete
// tool calls (called once finish_reason arrives) and resets the
// accumulator for any further choices/events.
flushToolCalls := func ( ) [ ] llm . ToolCall {
if len ( toolCallOrder ) == 0 {
return nil
}
calls := make ( [ ] llm . ToolCall , 0 , len ( toolCallOrder ) )
for _ , idx := range toolCallOrder {
frag := toolCallFrags [ idx ]
calls = append ( calls , llm . ToolCall {
ID : frag . id ,
Name : frag . name ,
Arguments : json . RawMessage ( frag . args . String ( ) ) ,
} )
}
toolCallFrags = map [ int ] * toolCallAccum { }
toolCallOrder = nil
return calls
}
2026-07-15 21:50:45 +00:00
// Client-side thinking-budget enforcement: llama.cpp silently drops
// the max_thinking_tokens JSON field, so without this a model in a
// reasoning spiral runs until max_tokens (seen live: 25k+ tokens of
// nonstop thinking). Token counts aren't available per delta, so the
// budget is tracked as an estimate in characters; once exceeded — and
// only while the model is still purely thinking — the stream ends
// with FinishThinkingBudget and the deferred Body.Close() aborts the
// server-side generation, freeing the slot immediately.
reasoningBudget := 0
if c . maxThinkingTokens > 0 {
reasoningBudget = c . maxThinkingTokens * reasoningCharsPerToken
}
reasoningChars := 0
answerStarted := false
2026-07-01 06:53:22 +00:00
scanner := bufio . NewScanner ( resp . Body )
2026-07-12 23:14:15 +00:00
// A single SSE line can exceed bufio.Scanner's 64KB default cap
// (e.g. a large tool-call arguments delta or a long reasoning
// event), which would kill the stream mid-turn with "token too
// long" — same headroom the anthropic client already reserves.
scanner . Buffer ( make ( [ ] byte , 0 , 64 * 1024 ) , 4 * 1024 * 1024 )
2026-07-01 06:53:22 +00:00
for scanner . Scan ( ) {
line := scanner . Text ( )
if ! strings . HasPrefix ( line , "data: " ) {
continue
}
data := strings . TrimPrefix ( line , "data: " )
if data == "[DONE]" {
return
}
var event llamaStreamEvent
if err := json . Unmarshal ( [ ] byte ( data ) , & event ) ; err != nil {
yield ( llm . StreamChunk { } , fmt . Errorf ( "decoding event: %w" , err ) )
return
}
2026-07-06 07:05:30 +00:00
var usage llm . TokenUsage
if event . Usage != nil {
usage = llm . TokenUsage {
InputTokens : event . Usage . PromptTokens ,
OutputTokens : event . Usage . CompletionTokens ,
TotalTokens : event . Usage . TotalTokens ,
}
}
if len ( event . Choices ) == 0 {
// The usage-only event (per stream_options.include_usage)
// carries no choices, so it needs its own chunk.
if event . Usage != nil {
if ! yield ( llm . StreamChunk { Usage : usage } , nil ) {
return
}
}
continue
}
2026-07-01 06:53:22 +00:00
for _ , choice := range event . Choices {
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
hasFragment := len ( choice . Delta . ToolCalls ) > 0
for _ , tc := range choice . Delta . ToolCalls {
frag , ok := toolCallFrags [ tc . Index ]
if ! ok {
frag = & toolCallAccum { }
toolCallFrags [ tc . Index ] = frag
toolCallOrder = append ( toolCallOrder , tc . Index )
}
if tc . ID != "" {
frag . id = tc . ID
}
if tc . Function . Name != "" {
frag . name = tc . Function . Name
}
frag . args . WriteString ( tc . Function . Arguments )
}
2026-07-01 06:53:22 +00:00
chunk := llm . StreamChunk {
2026-07-05 23:17:37 +00:00
Delta : choice . Delta . Content ,
ReasoningDelta : choice . Delta . ReasoningContent ,
2026-07-06 07:05:30 +00:00
Usage : usage ,
2026-07-01 06:53:22 +00:00
}
if choice . FinishReason != "" {
chunk . FinishReason = choice . FinishReason
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
chunk . ToolCalls = flushToolCalls ( )
}
2026-07-15 21:50:45 +00:00
reasoningChars += len ( choice . Delta . ReasoningContent )
if choice . Delta . Content != "" {
answerStarted = true
}
// Cut only while the round is pure reasoning: once the answer
// or a tool call has started streaming, the spiral risk is
// over and cutting would destroy real work in flight.
if reasoningBudget > 0 && reasoningChars > reasoningBudget &&
! answerStarted && len ( toolCallFrags ) == 0 && chunk . FinishReason == "" {
chunk . FinishReason = llm . FinishThinkingBudget
yield ( chunk , nil )
return
}
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
// A fragment-only event (a piece of a tool call's streamed
// arguments, with nothing else in this delta) has nothing
// yet for the agent loop to act on: it was buffered above,
// so skip yielding an empty chunk for it.
if hasFragment && chunk . Delta == "" && chunk . ReasoningDelta == "" && chunk . FinishReason == "" {
continue
2026-07-01 06:53:22 +00:00
}
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
2026-07-01 06:53:22 +00:00
if ! yield ( chunk , nil ) {
return
}
}
}
if err := scanner . Err ( ) ; err != nil {
yield ( llm . StreamChunk { } , fmt . Errorf ( "stream error: %w" , err ) )
}
}
}
func ( c * Client ) Name ( ) string {
return "llama.cpp"
}
func ( c * Client ) Capabilities ( ) llm . ProviderCapabilities {
return llm . ProviderCapabilities {
SupportsTools : true ,
2026-07-17 05:23:54 +00:00
SupportsVision : true ,
SupportsVideo : true ,
2026-07-01 06:53:22 +00:00
SupportsJSON : true ,
2026-07-09 06:33:20 +00:00
MaxContextWindow : c . contextWindow ,
2026-07-01 06:53:22 +00:00
}
}
// buildRequest converts an llm.CompletionRequest to the llama.cpp API format.
func ( c * Client ) buildRequest ( req llm . CompletionRequest , stream bool ) ( io . Reader , error ) {
messages := make ( [ ] llamaMessage , len ( req . Messages ) )
for i , m := range req . Messages {
2026-07-17 05:23:54 +00:00
content , err := buildContentValue ( m )
if err != nil {
return nil , err
}
2026-07-01 06:53:22 +00:00
messages [ i ] = llamaMessage {
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
Role : string ( m . Role ) ,
2026-07-17 05:23:54 +00:00
Content : content ,
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
ToolCallID : m . ToolCallID ,
Name : m . Name ,
}
if len ( m . ToolCalls ) > 0 {
calls := make ( [ ] llamaToolCall , len ( m . ToolCalls ) )
for j , tc := range m . ToolCalls {
calls [ j ] = llamaToolCall {
ID : tc . ID ,
Type : "function" ,
Function : llamaFunction {
Name : tc . Name ,
Arguments : string ( tc . Arguments ) ,
} ,
}
}
messages [ i ] . ToolCalls = calls
2026-07-01 06:53:22 +00:00
}
}
var tools [ ] llamaTool
for _ , t := range req . Tools {
var tool llamaTool
if err := json . Unmarshal ( t , & tool ) ; err != nil {
return nil , fmt . Errorf ( "parsing tool: %w" , err )
}
tools = append ( tools , tool )
}
2026-07-09 06:33:20 +00:00
model := req . Model
if model == "" {
model = c . model
}
2026-07-01 06:53:22 +00:00
openReq := llamaChatRequest {
2026-07-09 06:33:20 +00:00
Model : model ,
2026-07-03 21:22:36 +00:00
Messages : messages ,
Stream : stream ,
ChatTemplateKwargs : req . ChatTemplateKwargs ,
2026-07-09 06:33:20 +00:00
// Client-level sampling defaults (from Config, e.g. the local
// model's configured temperature/top_p/top_k/etc.) go first; a
// per-request override below takes precedence when set.
Temperature : c . temperature ,
MaxTokens : c . maxTokens ,
TopK : c . topK ,
TopP : c . topP ,
MinP : c . minP ,
PresencePenalty : c . presencePenalty ,
RepeatPenalty : c . repetitionPenalty ,
MaxThinkingTokens : c . maxThinkingTokens ,
2026-07-01 06:53:22 +00:00
}
2026-07-06 07:05:30 +00:00
if stream {
// Ask for a final SSE event carrying token usage (OpenAI-style
// streaming omits it otherwise), so Rony can track real token
// counts per turn instead of always seeing zero.
openReq . StreamOptions = & llamaStreamOptions { IncludeUsage : true }
}
2026-07-01 06:53:22 +00:00
if len ( tools ) > 0 {
openReq . Tools = tools
}
if req . ToolChoice != nil {
openReq . ToolChoice = req . ToolChoice
}
if req . Temperature != nil {
2026-07-09 06:33:20 +00:00
openReq . Temperature = * req . Temperature
2026-07-01 06:53:22 +00:00
}
if req . MaxTokens != nil {
2026-07-09 06:33:20 +00:00
openReq . MaxTokens = * req . MaxTokens
2026-07-01 06:53:22 +00:00
}
if len ( req . Stop ) > 0 {
openReq . Stop = req . Stop
}
data , err := json . Marshal ( openReq )
if err != nil {
return nil , fmt . Errorf ( "marshaling request: %w" , err )
}
2026-07-12 23:14:15 +00:00
return bytes . NewReader ( data ) , nil
2026-07-01 06:53:22 +00:00
}
// toResponse converts a llama.cpp API response to our CompletionResponse.
2026-07-12 23:14:15 +00:00
func ( c * Client ) toResponse ( resp llamaChatResponse ) ( llm . CompletionResponse , error ) {
// Guard against a 200 response with no choices (e.g. a misbehaving
// server or proxy) — indexing Choices[0] blindly panics the whole app.
if len ( resp . Choices ) == 0 {
return llm . CompletionResponse { } , fmt . Errorf ( "llamacpp: response contained no choices" )
}
2026-07-01 06:53:22 +00:00
choice := resp . Choices [ 0 ]
result := llm . CompletionResponse {
ID : resp . ID ,
Model : resp . Model ,
Content : choice . Message . Content ,
2026-07-05 23:17:37 +00:00
Reasoning : choice . Message . ReasoningContent ,
2026-07-01 06:53:22 +00:00
StopReason : choice . FinishReason ,
}
for _ , tc := range choice . Message . ToolCalls {
result . ToolCalls = append ( result . ToolCalls , llm . ToolCall {
ID : tc . ID ,
Name : tc . Function . Name ,
Arguments : json . RawMessage ( tc . Function . Arguments ) ,
} )
}
result . Usage = llm . TokenUsage {
InputTokens : resp . Usage . PromptTokens ,
OutputTokens : resp . Usage . CompletionTokens ,
TotalTokens : resp . Usage . TotalTokens ,
}
2026-07-12 23:14:15 +00:00
return result , nil
2026-07-01 06:53:22 +00:00
}
// llama.cpp API types
type llamaChatRequest struct {
2026-07-12 23:14:15 +00:00
Model string ` json:"model" `
Messages [ ] llamaMessage ` json:"messages" `
Tools [ ] llamaTool ` json:"tools,omitempty" `
ToolChoice interface { } ` json:"tool_choice,omitempty" `
Temperature float32 ` json:"temperature,omitempty" `
MaxTokens int ` json:"max_tokens,omitempty" `
TopK int ` json:"top_k,omitempty" `
TopP float32 ` json:"top_p,omitempty" `
MinP float32 ` json:"min_p,omitempty" `
PresencePenalty float32 ` json:"presence_penalty,omitempty" `
RepeatPenalty float32 ` json:"repeat_penalty,omitempty" `
2026-07-09 06:33:20 +00:00
// MaxThinkingTokens is a best-effort reasoning-token cap: not part of
// upstream llama.cpp's server API, but harmless to send since JSON
// servers ignore unrecognized fields, and some front-ends (e.g. the
// proxy this model's config was written for) do honor it.
2026-07-12 23:14:15 +00:00
MaxThinkingTokens int ` json:"max_thinking_tokens,omitempty" `
Stop [ ] string ` json:"stop,omitempty" `
Stream bool ` json:"stream" `
2026-07-06 07:05:30 +00:00
StreamOptions * llamaStreamOptions ` json:"stream_options,omitempty" `
2026-07-12 23:14:15 +00:00
ChatTemplateKwargs map [ string ] any ` json:"chat_template_kwargs,omitempty" `
2026-07-01 06:53:22 +00:00
}
2026-07-06 07:05:30 +00:00
type llamaStreamOptions struct {
IncludeUsage bool ` json:"include_usage" `
}
2026-07-01 06:53:22 +00:00
type llamaMessage struct {
2026-07-17 05:23:54 +00:00
Role string ` json:"role" `
// Content is either a plain string (the common case) or a
// []llamaContentPart when the source llm.Message carried Parts - see
// buildContentValue.
Content interface { } ` json:"content" `
Fix tool call tracking and streaming assembly for all providers
- agent/loop.go: Record assistant message with ToolCalls before tool results,
and set ToolCallID on tool-result messages so follow-up requests have complete
context (prevents models from losing track of already-attempted tools).
- llm/providers/llamacpp/client.go: Buffer fragmented tool call deltas during
streaming, assemble them into complete calls when finish_reason arrives.
Add ToolCalls, ToolCallID, Name fields to request building.
- llm/providers/openai/client.go: Send ToolCalls, ToolCallID, Name when
building chat requests so messages are wire-format correct.
- llm/types.go: Add ToolCalls field to Message struct for serialization
back into conversation history.
- agent/integration_test.go: Move integration test skip from TestMain to a
per-test skipUnlessIntegration() so it doesn't hide other package tests.
- sandbox & tools: Add edge-case tests (relative traversal, array paths,
non-path strings, zero-value guards, sentinel errors).
2026-07-08 23:11:57 +00:00
ToolCallID string ` json:"tool_call_id,omitempty" `
Name string ` json:"name,omitempty" `
ToolCalls [ ] llamaToolCall ` json:"tool_calls,omitempty" `
2026-07-01 06:53:22 +00:00
}
2026-07-17 05:23:54 +00:00
// llamaContentPart is one block of a multipart "content" array, following
// the same OpenAI-compatible shape llama.cpp's server accepts for
// vision-capable models (e.g. Qwen2-VL via its mmproj).
type llamaContentPart struct {
Type string ` json:"type" `
Text string ` json:"text,omitempty" `
ImageURL * llamaMediaURL ` json:"image_url,omitempty" `
VideoURL * llamaMediaURL ` json:"video_url,omitempty" `
}
type llamaMediaURL struct {
URL string ` json:"url" `
}
// buildContentValue converts an llm.Message's Parts into the OpenAI-style
// multipart content shape, or falls back to the plain Content string when
// there are no Parts. Unlike the openai/anthropic clients, a video part is
// passed through as a "video_url" block rather than rejected: llama.cpp
// itself has no video support, but this client's whole reason to exist is
// the user's own OpenAI-compatible server sitting in front of a
// video-capable model, so the server - not this client - is what decides
// whether it understands it.
func buildContentValue ( m llm . Message ) ( interface { } , error ) {
if len ( m . Parts ) == 0 {
return m . Content , nil
}
parts := make ( [ ] llamaContentPart , 0 , len ( m . Parts ) )
for _ , p := range m . Parts {
switch p . Type {
case "text" :
parts = append ( parts , llamaContentPart { Type : "text" , Text : p . Text } )
case "image" :
parts = append ( parts , llamaContentPart { Type : "image_url" , ImageURL : & llamaMediaURL { URL : p . MediaURL } } )
case "video" :
parts = append ( parts , llamaContentPart { Type : "video_url" , VideoURL : & llamaMediaURL { URL : p . MediaURL } } )
default :
return nil , fmt . Errorf ( "llamacpp: unknown content part type %q" , p . Type )
}
}
return parts , nil
}
2026-07-01 06:53:22 +00:00
type llamaTool struct {
Type string ` json:"type" `
Function json . RawMessage ` json:"function" `
}
type llamaChatResponse struct {
2026-07-12 23:14:15 +00:00
ID string ` json:"id" `
Model string ` json:"model" `
Choices [ ] llamaChoice ` json:"choices" `
Usage llamaUsage ` json:"usage" `
2026-07-01 06:53:22 +00:00
}
type llamaChoice struct {
Index int ` json:"index" `
Message llamaMessageResult ` json:"message" `
2026-07-12 23:14:15 +00:00
FinishReason string ` json:"finish_reason" `
2026-07-01 06:53:22 +00:00
}
type llamaMessageResult struct {
2026-07-05 23:17:37 +00:00
Role string ` json:"role" `
Content string ` json:"content" `
ReasoningContent string ` json:"reasoning_content" `
ToolCalls [ ] llamaToolCall ` json:"tool_calls" `
2026-07-01 06:53:22 +00:00
}
type llamaToolCall struct {
2026-07-12 23:14:15 +00:00
ID string ` json:"id" `
Type string ` json:"type" `
Function llamaFunction ` json:"function" `
2026-07-01 06:53:22 +00:00
}
type llamaFunction struct {
2026-07-12 23:14:15 +00:00
Name string ` json:"name" `
Arguments string ` json:"arguments" `
2026-07-01 06:53:22 +00:00
}
type llamaUsage struct {
PromptTokens int ` json:"prompt_tokens" `
CompletionTokens int ` json:"completion_tokens" `
TotalTokens int ` json:"total_tokens" `
}
// Stream event types
type llamaStreamEvent struct {
2026-07-12 23:14:15 +00:00
ID string ` json:"id" `
2026-07-01 06:53:22 +00:00
Choices [ ] llamaStreamChoice ` json:"choices" `
2026-07-12 23:14:15 +00:00
Usage * llamaUsage ` json:"usage" `
2026-07-01 06:53:22 +00:00
}
type llamaStreamChoice struct {
2026-07-12 23:14:15 +00:00
Index int ` json:"index" `
Delta llamaStreamDelta ` json:"delta" `
FinishReason string ` json:"finish_reason" `
2026-07-01 06:53:22 +00:00
}
type llamaStreamDelta struct {
2026-07-05 23:17:37 +00:00
Content string ` json:"content" `
ReasoningContent string ` json:"reasoning_content" `
2026-07-12 23:14:15 +00:00
Role string ` json:"role" `
ToolCalls [ ] llamaStreamToolCall ` json:"tool_calls" `
2026-07-01 06:53:22 +00:00
}
type llamaStreamToolCall struct {
2026-07-12 23:14:15 +00:00
Index int ` json:"index" `
ID string ` json:"id" `
Type string ` json:"type" `
Function llamaStreamFunction ` json:"function" `
2026-07-01 06:53:22 +00:00
}
type llamaStreamFunction struct {
2026-07-12 23:14:15 +00:00
Name string ` json:"name" `
Arguments string ` json:"arguments" `
2026-07-01 06:53:22 +00:00
}