feat: function-calling (MCP-style) tests plus ultra-hard tasks

Adds real OpenAI function-calling support end to end and two test tranches.

Client (internal/llm): Request.Tools advertises tools with tool_choice
auto; Response.ToolCalls carries the model's calls, decoding each
function's JSON-encoded argument string into an object (malformed
arguments preserved raw with Decoded=false). Verified live: the gateway
round-trips tools and the qwen/deepseek tool-call parsers emit the
expected format.

Runner: when a test declares Tools, it serializes the model's tool calls
into a canonical JSON envelope and scores that, not the free text.

Eval (toolcall.go): ToolCalled, NoToolCalled, ToolCallWithArgs (subset
match, number/string coercion), ToolSequence, FirstToolIs, ToolNotCalled,
ToolArgValuesCover - all parse the envelope, no llm dependency.

Tests: 12 function-calling tests (tools/function-calling) covering tool
selection, argument extraction, enum constraints, chained and parallel
calls, no-tool-needed and missing-param abstention, destructive-tool
safety, and name-vs-description traps; 8 ultra-hard single-shot tasks in
existing subcategories. Ground-truth self-consistency tests assert every
documented answer and ideal tool envelope scores 1.0.
This commit is contained in:
2026-08-30 20:22:20 +01:00
parent 708622e2a8
commit cfdb2c5aae
13 changed files with 1076 additions and 9 deletions
+68 -3
View File
@@ -40,7 +40,9 @@ func NewOpenAIClient(endpoint, apiKey string, timeout time.Duration) *OpenAIClie
type chatCompletionRequest struct {
Model string `json:"model"`
ToolChoice string `json:"tool_choice,omitempty"`
Messages []chatMsg `json:"messages"`
Tools []apiTool `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature"`
}
@@ -50,6 +52,28 @@ type chatMsg struct {
Content string `json:"content"`
}
// apiTool is the OpenAI tools[] wire shape: {"type":"function","function":{...}}.
type apiTool struct {
Function apiToolFunction `json:"function"`
Type string `json:"type"`
}
type apiToolFunction struct {
Parameters map[string]any `json:"parameters,omitempty"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
// apiToolCall is the OpenAI tool_calls[] wire shape. Function.Arguments is a
// JSON-encoded STRING, not a nested object, per the OpenAI spec.
type apiToolCall struct {
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
Type string `json:"type"`
}
// responseMsg is the assistant message inside one response choice. Content
// is a pointer, not a plain string: some OpenAI-compatible backends return
// "content": null when generation was cut off before any answer token was
@@ -61,8 +85,9 @@ type chatMsg struct {
// Deliberately not evaluated: reasoning_content. The catalog's evaluators
// score the answer a user would actually see, which is content only.
type responseMsg struct {
Content *string `json:"content"`
Role string `json:"role"`
Content *string `json:"content"`
Role string `json:"role"`
ToolCalls []apiToolCall `json:"tool_calls"`
}
// Text returns m.Content, or "" if the backend sent "content": null.
@@ -78,8 +103,8 @@ type chatCompletionResponse struct {
Message string `json:"message"`
} `json:"error"`
Choices []struct {
Message responseMsg `json:"message"`
FinishReason string `json:"finish_reason"`
Message responseMsg `json:"message"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
@@ -87,6 +112,32 @@ type chatCompletionResponse struct {
} `json:"usage"`
}
// decodeToolCalls converts the OpenAI wire tool_calls into normalized
// ToolCall values, decoding each function's JSON-encoded argument string
// into an object. A call whose argument string is not valid JSON is kept
// with Decoded=false and its raw string preserved, so a test can still see
// that the tool was named even when the model emitted malformed arguments.
func decodeToolCalls(raw []apiToolCall) []ToolCall {
if len(raw) == 0 {
return nil
}
out := make([]ToolCall, 0, len(raw))
for _, tc := range raw {
call := ToolCall{Name: tc.Function.Name, RawArguments: tc.Function.Arguments}
var args map[string]any
if tc.Function.Arguments == "" {
// No-argument tool call: an empty argument object, decoded.
call.Arguments = map[string]any{}
call.Decoded = true
} else if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err == nil {
call.Arguments = args
call.Decoded = true
}
out = append(out, call)
}
return out
}
// Complete implements Client. It retries transport failures and 5xx
// responses up to maxRetries times with linear backoff.
func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, error) {
@@ -98,6 +149,17 @@ func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, err
for _, m := range req.Messages {
body.Messages = append(body.Messages, chatMsg(m))
}
if len(req.Tools) > 0 {
// tool_choice "auto" lets the model decide whether and which tool to
// call: the honest test of both "must call X" and "needs no tool".
body.ToolChoice = "auto"
for _, t := range req.Tools {
body.Tools = append(body.Tools, apiTool{
Type: "function",
Function: apiToolFunction(t),
})
}
}
payload, err := json.Marshal(body)
if err != nil {
@@ -130,9 +192,11 @@ func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, err
latency := time.Since(attemptStart)
var text, finishReason string
var toolCalls []ToolCall
if len(resp.Choices) > 0 {
text = resp.Choices[0].Message.Text()
finishReason = resp.Choices[0].FinishReason
toolCalls = decodeToolCalls(resp.Choices[0].Message.ToolCalls)
}
if resp.Error != nil {
// A 2xx carrying a JSON error body is how some gateways report a
@@ -145,6 +209,7 @@ func (c *OpenAIClient) Complete(ctx context.Context, req Request) (Response, err
return Response{
Text: text,
FinishReason: finishReason,
ToolCalls: toolCalls,
PromptTokens: resp.Usage.PromptTokens,
CompletionTokens: resp.Usage.CompletionTokens,
Latency: latency,