char-fork/llama.cpp
git clone https://git.t4t.associates/char-fork/llama.cpp
8bccdbbff
char/aliceai-t5-moe
1#!/usr/bin/env python3 2""" 3Test parallel tool-calling capability via chat completions endpoint. 4 5Only run this against models that actually support parallel tool calls — this 6script does not attempt to toggle that setting on the server. Each scenario is 7explicitly worded so that a capable model SHOULD emit multiple tool calls in a 8single assistant turn (either the same tool N times, or several different 9tools at once). 10 11Each test case contains: 12- tools: list of tool definitions (OpenAI-compatible) 13- messages: initial conversation messages 14- mock_tool_responses: dict mapping tool_name -> callable(arguments) -> str (JSON) 15- expected_parallel: dict describing what constitutes a successful parallel turn 16{"min_parallel": int, # minimum tool_calls in one turn 17"require_same_tool": Optional[str], # all parallel calls must be this tool 18"require_distinct_tools": Optional[int], # >= N distinct tool names in one turn 19"min_distinct_args_key": Optional[str]} # parallel calls must span this 20# many distinct values of this arg key 21- validate: callable(turns, all_tool_calls, final_content) -> (passed, reason) 22""" 23 24import argparse 25import json 26import requests 27import sys 28 29# --------------------------------------------------------------------------- 30# Color / formatting helpers 31# --------------------------------------------------------------------------- 32 33RESET = "\x1b[0m" 34BOLD = "\x1b[1m" 35DIM = "\x1b[2m" 36CYAN = "\x1b[36m" 37YELLOW = "\x1b[33m" 38GREEN = "\x1b[32m" 39RED = "\x1b[31m" 40BLUE = "\x1b[34m" 41WHITE = "\x1b[97m" 42MAGENTA = "\x1b[35m" 43 44 45def _print (text = "" ,end = "\n" ): 46sys .stdout .write (text + end ) 47sys .stdout .flush () 48 49 50def print_header (title ): 51bar = "─" * 60 52_print (f"\n { BOLD } { CYAN } ┌ { bar } ┐ { RESET } " ) 53_print ( 54f" { BOLD } { CYAN } │ { WHITE } { title } { CYAN } { ' ' * max ( 0 , 58 - len ( title )) } │ { RESET } " 55 ) 56_print (f" { BOLD } { CYAN } └ { bar } ┘ { RESET } " ) 57 58 59def print_turn_banner (turn_idx ,n_calls ): 60color = MAGENTA if n_calls >= 2 else DIM 61_print (f"\n { BOLD } { color } ▶ turn { turn_idx } — { n_calls } tool call(s) { RESET } " ) 62 63 64def print_tool_call (name ,args ): 65args_str = json .dumps (args ) 66_print ( 67f" { BOLD } { YELLOW } ⚙ { name } { RESET } { DIM } ( { args_str } ) { RESET } " 68 ) 69 70 71def print_tool_result (result ): 72preview = result [:140 ]+ ("…" if len (result )> 140 else "" ) 73_print (f" { DIM } { BLUE } ↳ { preview } { RESET } " ) 74 75 76def print_model_output (text ): 77sys .stdout .write (text ) 78sys .stdout .flush () 79 80 81def print_pass (reason ): 82_print (f"\n { BOLD } { GREEN } ✔ PASS { RESET } { reason } " ) 83 84 85def print_fail (reason ): 86_print (f"\n { BOLD } { RED } ✘ FAIL { RESET } { reason } " ) 87 88 89def print_info (msg ): 90_print (f" { DIM } { msg } { RESET } " ) 91 92 93def print_warn (msg ): 94_print (f" { BOLD } { YELLOW } ⚠ { msg } { RESET } " ) 95 96 97# --------------------------------------------------------------------------- 98# HTTP helpers 99# --------------------------------------------------------------------------- 100 101 102def chat_completion (url ,messages ,tools = None ,stream = False ): 103payload = { 104"messages" :messages , 105"stream" :stream , 106"max_tokens" :4096 , 107 } 108if tools : 109payload ["tools" ]= tools 110payload ["tool_choice" ]= "auto" 111 112try : 113response = requests .post (url ,json = payload ,stream = stream ) 114response .raise_for_status () 115except requests .exceptions .RequestException as e : 116body = e .response .content if (e .response is not None )else b"" 117print_fail (f"Request error: { e } | body: { body } " ) 118return None 119 120full_content = "" 121reasoning_content = "" 122tool_calls :list [dict ]= [] 123 124if stream : 125for line in response .iter_lines (): 126if not line : 127continue 128decoded = line .decode ("utf-8" ) 129if not decoded .startswith ("data: " ): 130continue 131data_str = decoded [6 :] 132if data_str == "[DONE]" : 133break 134try : 135data = json .loads (data_str ) 136except json .JSONDecodeError : 137continue 138choices = data .get ("choices" , []) 139if not choices : 140continue 141delta = choices [0 ].get ("delta" , {}) 142if delta .get ("reasoning_content" ): 143reasoning_content += delta ["reasoning_content" ] 144if delta .get ("content" ): 145full_content += delta ["content" ] 146print_model_output (delta ["content" ]) 147for tc in delta .get ("tool_calls" , []): 148idx = tc .get ("index" ,0 ) 149while len (tool_calls )<= idx : 150tool_calls .append ( 151 { 152"id" :"" , 153"type" :"function" , 154"function" : {"name" :"" ,"arguments" :"" }, 155 } 156 ) 157if "id" in tc : 158tool_calls [idx ]["id" ]+= tc ["id" ] 159if "function" in tc : 160if "name" in tc ["function" ]: 161tool_calls [idx ]["function" ]["name" ]+= tc ["function" ]["name" ] 162if "arguments" in tc ["function" ]: 163tool_calls [idx ]["function" ]["arguments" ]+= tc ["function" ][ 164"arguments" 165 ] 166else : 167data = response .json () 168choices = data .get ("choices" , []) 169if choices : 170msg = choices [0 ].get ("message" , {}) 171full_content = msg .get ("content" )or "" 172reasoning_content = msg .get ("reasoning_content" )or "" 173tool_calls = msg .get ("tool_calls" )or [] 174if full_content : 175print_model_output (full_content ) 176 177result = {"content" :full_content ,"tool_calls" :tool_calls } 178if reasoning_content : 179result ["reasoning_content" ]= reasoning_content 180return result 181 182 183def run_agentic_loop (url ,messages ,tools ,mock_tool_responses ,stream ,max_turns = 6 ): 184""" 185Drive the multi-turn tool-call loop, but record each turn's tool calls 186separately so parallelism can be validated. 187 188Returns (turns, all_tool_calls, final_content) where `turns` is a list 189of dicts: {"index": int, "tool_calls": [...], "content": str}. 190""" 191msgs = list (messages ) 192turns :list [dict ]= [] 193all_tool_calls :list [dict ]= [] 194 195for turn_idx in range (max_turns ): 196result = chat_completion (url ,msgs ,tools = tools ,stream = stream ) 197if result is None : 198return turns ,all_tool_calls ,None 199 200tcs = result .get ("tool_calls" )or [] 201content = result .get ("content" )or "" 202 203turns .append ( 204 {"index" :turn_idx ,"tool_calls" :list (tcs ),"content" :content } 205 ) 206 207if not tcs : 208if content : 209_print (f"\n { DIM } { '·' * 60 } { RESET } " ) 210_print (f" { DIM } model response: { RESET } \n" ) 211return turns ,all_tool_calls ,content 212 213print_turn_banner (turn_idx ,len (tcs )) 214all_tool_calls .extend (tcs ) 215 216assistant_msg :dict = { 217"role" :"assistant" , 218"content" :content , 219"tool_calls" :tcs , 220 } 221reasoning = result .get ("reasoning_content" ) 222if reasoning : 223assistant_msg ["reasoning_content" ]= reasoning 224msgs .append (assistant_msg ) 225 226for tc in tcs : 227tool_name = tc ["function" ]["name" ] 228try : 229args = json .loads (tc ["function" ]["arguments" ]) 230except json .JSONDecodeError : 231args = {} 232 233print_tool_call (tool_name ,args ) 234 235mock_fn = mock_tool_responses .get (tool_name ) 236if mock_fn : 237tool_result = mock_fn (args ) 238else : 239tool_result = json .dumps ({"error" :f"Unknown tool: { tool_name } " }) 240 241print_tool_result (tool_result ) 242 243msgs .append ( 244 { 245"role" :"tool" , 246"tool_call_id" :tc .get ("id" ,"" ), 247"content" :tool_result , 248 } 249 ) 250 251return turns ,all_tool_calls ,None 252 253 254# --------------------------------------------------------------------------- 255# Parallelism helpers 256# --------------------------------------------------------------------------- 257 258 259def _best_parallel_turn (turns ): 260"""Return the turn (dict) with the most tool calls, or None if no tools.""" 261tool_turns = [t for t in turns if t ["tool_calls" ]] 262if not tool_turns : 263return None 264return max (tool_turns ,key = lambda t :len (t ["tool_calls" ])) 265 266 267def _distinct_tool_names (turn ): 268return {tc ["function" ]["name" ]for tc in turn ["tool_calls" ]} 269 270 271def _distinct_arg_values (turn ,key ): 272values = set () 273for tc in turn ["tool_calls" ]: 274try : 275args = json .loads (tc ["function" ]["arguments" ]) 276except json .JSONDecodeError : 277continue 278v = args .get (key ) 279if v is not None : 280if isinstance (v ,str ): 281values .add (v .strip ().lower ()) 282else : 283values .add (v ) 284return values 285 286 287def _check_parallel (turns ,expected ): 288""" 289Check that at least one turn satisfies the parallel-call expectations. 290Returns (ok, reason). 291""" 292best = _best_parallel_turn (turns ) 293if best is None : 294return False ,"No tool calls were made at all" 295 296min_parallel = expected .get ("min_parallel" ,2 ) 297if len (best ["tool_calls" ])< min_parallel : 298by_turn = [len (t ["tool_calls" ])for t in turns ] 299return False , ( 300f"No turn had >= { min_parallel } parallel tool calls " 301f"(per-turn counts: { by_turn } )" 302 ) 303 304require_same = expected .get ("require_same_tool" ) 305if require_same is not None : 306names = [tc ["function" ]["name" ]for tc in best ["tool_calls" ]] 307if any (n != require_same for n in names ): 308return False , ( 309f"Parallel turn mixed tools; expected all { require_same !r } , got { names } " 310 ) 311 312require_distinct = expected .get ("require_distinct_tools" ) 313if require_distinct is not None : 314distinct = _distinct_tool_names (best ) 315if len (distinct )< require_distinct : 316return False , ( 317f"Parallel turn had only { len ( distinct ) } distinct tool names " 318f"( { distinct } ); need >= { require_distinct } " 319 ) 320 321distinct_key = expected .get ("min_distinct_args_key" ) 322distinct_count = expected .get ("min_distinct_args_count" ,min_parallel ) 323if distinct_key is not None : 324values = _distinct_arg_values (best ,distinct_key ) 325if len (values )< distinct_count : 326return False , ( 327f"Parallel turn had only { len ( values ) } distinct { distinct_key !r } " 328f"values ( { values } ); need >= { distinct_count } " 329 ) 330 331return True , ( 332f"Parallel turn had { len ( best [ 'tool_calls' ]) } calls across " 333f" { len ( _distinct_tool_names ( best )) } distinct tool(s)" 334 ) 335 336 337# --------------------------------------------------------------------------- 338# Test case runner 339# --------------------------------------------------------------------------- 340 341 342def run_test (url ,test_case ,stream ): 343name = test_case ["name" ] 344mode = f" { 'stream' if stream else 'non-stream' } " 345print_header (f" { name } [ { mode } ]" ) 346 347turns ,all_tool_calls ,final_content = run_agentic_loop ( 348url , 349messages = test_case ["messages" ], 350tools = test_case ["tools" ], 351mock_tool_responses = test_case ["mock_tool_responses" ], 352stream = stream , 353 ) 354 355if not turns : 356print_fail ("No response from server." ) 357return False 358 359parallel_ok ,parallel_reason = _check_parallel (turns ,test_case ["expected_parallel" ]) 360if not parallel_ok : 361print_fail (parallel_reason ) 362return False 363 364passed ,reason = test_case ["validate" ](turns ,all_tool_calls ,final_content ) 365if passed : 366print_pass (f" { parallel_reason } ; { reason } " ) 367else : 368print_fail (reason ) 369return passed 370 371 372# --------------------------------------------------------------------------- 373# Test case definitions 374# --------------------------------------------------------------------------- 375 376# ---- Test 1: Multi-file read (same tool, multiple distinct paths) ---- 377 378_FILE_TOOLS = [ 379 { 380"type" :"function" , 381"function" : { 382"name" :"read_file" , 383"description" : ( 384"Read the full contents of a file from the local filesystem. " 385"Call this tool in parallel when asked to read several files — " 386"each path needs its own call." 387 ), 388"parameters" : { 389"type" :"object" , 390"properties" : { 391"path" : { 392"type" :"string" , 393"description" :"Absolute or repo-relative path to a file" , 394 }, 395 }, 396"required" : ["path" ], 397 }, 398 }, 399 }, 400] 401 402_FILE_CONTENTS = { 403"config/database.yml" :"host: db.internal\nport: 5432\nuser: svc_app\n" , 404"config/redis.yml" :"host: cache.internal\nport: 6379\ndb: 0\n" , 405"config/queue.yml" :"broker: rabbitmq.internal\nport: 5672\nvhost: prod\n" , 406"config/auth.yml" :"provider: oidc\nissuer: https://auth.internal\n" , 407} 408 409 410def _read_file_mock (args ): 411path = args .get ("path" ,"" ) 412norm = path .lstrip ("./" ).lstrip ("/" ) 413content = _FILE_CONTENTS .get (norm ) 414if content is None : 415for k ,v in _FILE_CONTENTS .items (): 416if path .endswith (k ): 417content = v 418break 419if content is None : 420return json .dumps ({"path" :path ,"error" :"not found" }) 421return json .dumps ({"path" :path ,"content" :content }) 422 423 424MULTIFILE_READ_TEST = { 425"name" :"Parallel multi-file read (same tool, 4 distinct paths)" , 426"tools" :_FILE_TOOLS , 427"messages" : [ 428 { 429"role" :"user" , 430"content" : ( 431"Please read all four of these config files so I can review them " 432"together: config/database.yml, config/redis.yml, config/queue.yml, " 433"and config/auth.yml. Call read_file for every path in parallel in " 434"a single batch — do NOT read them one by one sequentially across " 435"turns. After you have all four, give me a one-line summary of each." 436 ), 437 } 438 ], 439"mock_tool_responses" : {"read_file" :_read_file_mock }, 440"expected_parallel" : { 441"min_parallel" :4 , 442"require_same_tool" :"read_file" , 443"min_distinct_args_key" :"path" , 444"min_distinct_args_count" :4 , 445 }, 446"validate" :lambda turns ,tcs ,content :_validate_multifile (turns ,tcs ,content ), 447} 448 449 450def _validate_multifile (turns ,tcs ,content ): 451del turns 452if not content : 453return False ,"No final summary produced" 454return True ,f" { len ( tcs ) } total read_file calls; content length= { len ( content ) } " 455 456 457# ---- Test 2: Batch TODO marking (same tool, N calls in one turn) ---- 458 459_TODO_TOOLS = [ 460 { 461"type" :"function" , 462"function" : { 463"name" :"mark_todo_complete" , 464"description" : ( 465"Mark a single TODO item as complete by ID. When the user wants " 466"several items marked at once, call this tool in parallel — " 467"one call per item — rather than sequentially across turns." 468 ), 469"parameters" : { 470"type" :"object" , 471"properties" : { 472"todo_id" : { 473"type" :"string" , 474"description" :"Identifier of the TODO item" , 475 }, 476"note" : { 477"type" :"string" , 478"description" :"Optional completion note" , 479 }, 480 }, 481"required" : ["todo_id" ], 482 }, 483 }, 484 }, 485] 486 487_TODO_DB = { 488"T-101" :"Draft onboarding doc" , 489"T-102" :"Update dependency lockfile" , 490"T-103" :"Fix flaky login test" , 491"T-104" :"Rotate service credentials" , 492"T-105" :"Archive Q4 reports" , 493} 494 495 496def _mark_todo_mock (args ): 497tid = args .get ("todo_id" ,"" ) 498if tid in _TODO_DB : 499return json .dumps ({"todo_id" :tid ,"title" :_TODO_DB [tid ],"status" :"done" }) 500return json .dumps ({"todo_id" :tid ,"error" :"unknown id" }) 501 502 503TODO_BATCH_TEST = { 504"name" :"Batch TODO completion (same tool, 5 IDs in one turn)" , 505"tools" :_TODO_TOOLS , 506"messages" : [ 507 { 508"role" :"user" , 509"content" : ( 510"I finished every item on today's list. Please mark all of the " 511"following TODOs as complete, in one parallel batch: T-101, T-102, " 512"T-103, T-104, T-105. Don't mark them one at a time across separate " 513"turns — issue all five mark_todo_complete calls at once. Afterwards " 514"confirm which ones succeeded." 515 ), 516 } 517 ], 518"mock_tool_responses" : {"mark_todo_complete" :_mark_todo_mock }, 519"expected_parallel" : { 520"min_parallel" :5 , 521"require_same_tool" :"mark_todo_complete" , 522"min_distinct_args_key" :"todo_id" , 523"min_distinct_args_count" :5 , 524 }, 525"validate" :lambda turns ,tcs ,content :_validate_todo (turns ,tcs ,content ), 526} 527 528 529def _validate_todo (turns ,tcs ,content ): 530del turns 531if not content : 532return False ,"No confirmation summary produced" 533return True ,f" { len ( tcs ) } total mark_todo_complete calls" 534 535 536# ---- Test 3: Multi-city weather (same tool, N parallel locations) ---- 537 538_WEATHER_TOOLS = [ 539 { 540"type" :"function" , 541"function" : { 542"name" :"get_weather" , 543"description" : ( 544"Fetch current weather for ONE city. When the user asks about " 545"several cities, call this tool in parallel — one call per city — " 546"instead of sequentially." 547 ), 548"parameters" : { 549"type" :"object" , 550"properties" : { 551"city" : {"type" :"string" ,"description" :"City name" }, 552"units" : { 553"type" :"string" , 554"enum" : ["metric" ,"imperial" ], 555"default" :"metric" , 556 }, 557 }, 558"required" : ["city" ], 559 }, 560 }, 561 }, 562] 563 564_WEATHER_DB = { 565"tokyo" : {"city" :"Tokyo" ,"temp_c" :18.4 ,"condition" :"partly cloudy" ,"humidity" :64 }, 566"london" : {"city" :"London" ,"temp_c" :9.1 ,"condition" :"overcast" ,"humidity" :81 }, 567"new york" : {"city" :"New York" ,"temp_c" :12.7 ,"condition" :"clear" ,"humidity" :55 }, 568"paris" : {"city" :"Paris" ,"temp_c" :11.3 ,"condition" :"light rain" ,"humidity" :78 }, 569} 570 571 572def _weather_mock (args ): 573city = args .get ("city" ,"" ).strip ().lower () 574if city .startswith ("new york" ): 575city = "new york" 576if city in _WEATHER_DB : 577return json .dumps (_WEATHER_DB [city ]) 578return json .dumps ({"city" :args .get ("city" ,"" ),"error" :"unknown city" }) 579 580 581MULTI_WEATHER_TEST = { 582"name" :"Parallel multi-city weather (same tool, 4 cities)" , 583"tools" :_WEATHER_TOOLS , 584"messages" : [ 585 { 586"role" :"user" , 587"content" : ( 588"I'm comparing today's weather across four cities for a travel " 589"decision: Tokyo, London, New York, and Paris. Please call " 590"get_weather for all four in parallel in a single turn — don't " 591"fetch them one at a time. Then rank them from warmest to coolest." 592 ), 593 } 594 ], 595"mock_tool_responses" : {"get_weather" :_weather_mock }, 596"expected_parallel" : { 597"min_parallel" :4 , 598"require_same_tool" :"get_weather" , 599"min_distinct_args_key" :"city" , 600"min_distinct_args_count" :4 , 601 }, 602"validate" :lambda turns ,tcs ,content :_validate_weather (turns ,tcs ,content ), 603} 604 605 606def _validate_weather (turns ,tcs ,content ): 607del turns 608if not content or not any ( 609kw in content .lower ()for kw in ("warmest" ,"rank" ,"hot" ,"cool" ) 610 ): 611return False ,f"Final content missing a ranking: { content !r } " 612return True ,f" { len ( tcs ) } total get_weather calls; ranking produced" 613 614 615# ---- Test 4: Trip planning (different tools, parallel in one turn) ---- 616 617_TRIP_TOOLS = [ 618 { 619"type" :"function" , 620"function" : { 621"name" :"search_flights" , 622"description" :"Search one-way flights between two airports on a given date." , 623"parameters" : { 624"type" :"object" , 625"properties" : { 626"from_airport" : {"type" :"string" ,"description" :"IATA code, e.g. SFO" }, 627"to_airport" : {"type" :"string" ,"description" :"IATA code, e.g. JFK" }, 628"date" : {"type" :"string" ,"description" :"YYYY-MM-DD" }, 629 }, 630"required" : ["from_airport" ,"to_airport" ,"date" ], 631 }, 632 }, 633 }, 634 { 635"type" :"function" , 636"function" : { 637"name" :"search_hotels" , 638"description" :"Search hotels in a city for a date range." , 639"parameters" : { 640"type" :"object" , 641"properties" : { 642"city" : {"type" :"string" }, 643"check_in" : {"type" :"string" ,"description" :"YYYY-MM-DD" }, 644"check_out" : {"type" :"string" ,"description" :"YYYY-MM-DD" }, 645"max_price" : {"type" :"integer" }, 646 }, 647"required" : ["city" ,"check_in" ,"check_out" ], 648 }, 649 }, 650 }, 651 { 652"type" :"function" , 653"function" : { 654"name" :"search_restaurants" , 655"description" :"Search restaurants in a city by cuisine." , 656"parameters" : { 657"type" :"object" , 658"properties" : { 659"city" : {"type" :"string" }, 660"cuisine" : {"type" :"string" }, 661 }, 662"required" : ["city" ], 663 }, 664 }, 665 }, 666] 667 668_FLIGHTS_RESULT = { 669"results" : [ 670 {"flight" :"UA 1552" ,"depart" :"08:15" ,"arrive" :"16:45" ,"price" :389 }, 671 {"flight" :"AA 20" ,"depart" :"10:00" ,"arrive" :"18:35" ,"price" :412 }, 672 ] 673} 674_HOTELS_RESULT = { 675"results" : [ 676 {"name" :"Midtown Grand" ,"nightly_rate" :245 ,"rating" :4.3 }, 677 {"name" :"Harbour Boutique" ,"nightly_rate" :312 ,"rating" :4.6 }, 678 ] 679} 680_RESTAURANTS_RESULT = { 681"results" : [ 682 {"name" :"Trattoria Nona" ,"cuisine" :"italian" ,"rating" :4.5 }, 683 {"name" :"Osteria Blu" ,"cuisine" :"italian" ,"rating" :4.4 }, 684 ] 685} 686 687TRIP_PLAN_TEST = { 688"name" :"Trip planning (3 different tools in parallel)" , 689"tools" :_TRIP_TOOLS , 690"messages" : [ 691 { 692"role" :"user" , 693"content" : ( 694"I'm flying from SFO to JFK on 2026-06-12 and staying four nights " 695"(check out 2026-06-16). I'd also like some Italian restaurant " 696"suggestions in New York. Please call search_flights, search_hotels, " 697"and search_restaurants in parallel — all three in a single turn, " 698"since they don't depend on each other. Then give me a concise " 699"travel summary." 700 ), 701 } 702 ], 703"mock_tool_responses" : { 704"search_flights" :lambda _ :json .dumps (_FLIGHTS_RESULT ), 705"search_hotels" :lambda _ :json .dumps (_HOTELS_RESULT ), 706"search_restaurants" :lambda _ :json .dumps (_RESTAURANTS_RESULT ), 707 }, 708"expected_parallel" : { 709"min_parallel" :3 , 710"require_distinct_tools" :3 , 711 }, 712"validate" :lambda turns ,tcs ,content :_validate_trip (turns ,tcs ,content ), 713} 714 715 716def _validate_trip (turns ,tcs ,content ): 717del turns 718names = {tc ["function" ]["name" ]for tc in tcs } 719required = {"search_flights" ,"search_hotels" ,"search_restaurants" } 720missing = required - names 721if missing : 722return False ,f"Missing tool calls: { missing } " 723if not content : 724return False ,"No travel summary produced" 725return True ,f"All three tools called; summary length= { len ( content ) } " 726 727 728# ---- Test 5: Portfolio check (same tool, parallel tickers) ---- 729 730_STOCK_TOOLS = [ 731 { 732"type" :"function" , 733"function" : { 734"name" :"get_stock_quote" , 735"description" : ( 736"Get the latest quote for ONE ticker. When the user asks about " 737"multiple tickers, call this tool in parallel — one per symbol — " 738"rather than sequentially." 739 ), 740"parameters" : { 741"type" :"object" , 742"properties" : { 743"symbol" : {"type" :"string" ,"description" :"Ticker symbol" }, 744 }, 745"required" : ["symbol" ], 746 }, 747 }, 748 }, 749] 750 751_STOCK_DB = { 752"AAPL" : {"symbol" :"AAPL" ,"price" :218.45 ,"change_pct" :"+0.8%" }, 753"MSFT" : {"symbol" :"MSFT" ,"price" :421.10 ,"change_pct" :"+1.2%" }, 754"GOOGL" :{"symbol" :"GOOGL" ,"price" :175.22 ,"change_pct" :"-0.3%" }, 755"AMZN" : {"symbol" :"AMZN" ,"price" :189.76 ,"change_pct" :"+0.5%" }, 756"NVDA" : {"symbol" :"NVDA" ,"price" :140.88 ,"change_pct" :"+2.4%" }, 757} 758 759 760def _stock_mock (args ): 761sym = args .get ("symbol" ,"" ).strip ().upper () 762if sym in _STOCK_DB : 763return json .dumps (_STOCK_DB [sym ]) 764return json .dumps ({"symbol" :sym ,"error" :"unknown ticker" }) 765 766 767PORTFOLIO_TEST = { 768"name" :"Portfolio check (same tool, 5 tickers in parallel)" , 769"tools" :_STOCK_TOOLS , 770"messages" : [ 771 { 772"role" :"user" , 773"content" : ( 774"Pull the latest quote for every ticker in my portfolio — AAPL, " 775"MSFT, GOOGL, AMZN, and NVDA — in a single parallel batch. These " 776"lookups are independent, so please don't chain them across turns. " 777"Once you have all five, tell me which ticker had the biggest " 778"percentage change today." 779 ), 780 } 781 ], 782"mock_tool_responses" : {"get_stock_quote" :_stock_mock }, 783"expected_parallel" : { 784"min_parallel" :5 , 785"require_same_tool" :"get_stock_quote" , 786"min_distinct_args_key" :"symbol" , 787"min_distinct_args_count" :5 , 788 }, 789"validate" :lambda turns ,tcs ,content :_validate_portfolio (turns ,tcs ,content ), 790} 791 792 793def _validate_portfolio (turns ,tcs ,content ): 794del turns 795if not content or ("nvda" not in content .lower ()and "NVDA" not in content ): 796return False ,f"Expected NVDA to be identified as the biggest mover: { content !r } " 797return True ,f" { len ( tcs ) } total quotes pulled" 798 799 800# ---- Test 6: Mixed — translate + dictionary in parallel for the same word ---- 801 802_LANG_TOOLS = [ 803 { 804"type" :"function" , 805"function" : { 806"name" :"translate_text" , 807"description" :"Translate a short text into a target language." , 808"parameters" : { 809"type" :"object" , 810"properties" : { 811"text" : {"type" :"string" }, 812"target_language" : {"type" :"string" , 813"description" :"ISO 639-1 language code, e.g. 'es'" }, 814 }, 815"required" : ["text" ,"target_language" ], 816 }, 817 }, 818 }, 819 { 820"type" :"function" , 821"function" : { 822"name" :"get_definition" , 823"description" :"Get the English dictionary definition of a word." , 824"parameters" : { 825"type" :"object" , 826"properties" : { 827"word" : {"type" :"string" }, 828 }, 829"required" : ["word" ], 830 }, 831 }, 832 }, 833 { 834"type" :"function" , 835"function" : { 836"name" :"get_synonyms" , 837"description" :"Get English synonyms for a word." , 838"parameters" : { 839"type" :"object" , 840"properties" : { 841"word" : {"type" :"string" }, 842 }, 843"required" : ["word" ], 844 }, 845 }, 846 }, 847] 848 849 850def _translate_mock (args ): 851t = args .get ("text" ,"" ) 852lang = args .get ("target_language" ,"" ) 853return json .dumps ({"source" :t ,"target_language" :lang ,"translation" :f"[ { lang } ] { t } " }) 854 855 856def _definition_mock (args ): 857w = args .get ("word" ,"" ) 858return json .dumps ({ 859"word" :w , 860"definition" :f"A standard dictionary definition of { w !r } ." , 861 }) 862 863 864def _synonyms_mock (args ): 865w = args .get ("word" ,"" ) 866return json .dumps ({ 867"word" :w , 868"synonyms" : ["synonym_a" ,"synonym_b" ,"synonym_c" ], 869 }) 870 871 872LANG_TOOLKIT_TEST = { 873"name" :"Language toolkit (translate + definition + synonyms in parallel)" , 874"tools" :_LANG_TOOLS , 875"messages" : [ 876 { 877"role" :"user" , 878"content" : ( 879"For the English word 'resilient', I need three independent " 880"look-ups at once: (a) translate it into Spanish, (b) fetch its " 881"dictionary definition, and (c) list its synonyms. These three " 882"calls don't depend on each other — please issue them in parallel " 883"in a single turn. Then present the combined results as a short " 884"language note." 885 ), 886 } 887 ], 888"mock_tool_responses" : { 889"translate_text" :_translate_mock , 890"get_definition" :_definition_mock , 891"get_synonyms" :_synonyms_mock , 892 }, 893"expected_parallel" : { 894"min_parallel" :3 , 895"require_distinct_tools" :3 , 896 }, 897"validate" :lambda turns ,tcs ,content :_validate_lang (turns ,tcs ,content ), 898} 899 900 901def _validate_lang (turns ,tcs ,content ): 902del turns 903names = {tc ["function" ]["name" ]for tc in tcs } 904required = {"translate_text" ,"get_definition" ,"get_synonyms" } 905missing = required - names 906if missing : 907return False ,f"Missing tool calls: { missing } " 908if not content : 909return False ,"No language note produced" 910return True ,f"All three lookup tools called; note length= { len ( content ) } " 911 912 913# --------------------------------------------------------------------------- 914# All test cases 915# --------------------------------------------------------------------------- 916 917ALL_TEST_CASES = [ 918MULTIFILE_READ_TEST , 919TODO_BATCH_TEST , 920MULTI_WEATHER_TEST , 921TRIP_PLAN_TEST , 922PORTFOLIO_TEST , 923LANG_TOOLKIT_TEST , 924] 925 926 927# --------------------------------------------------------------------------- 928# Entry point 929# --------------------------------------------------------------------------- 930 931 932def main (): 933parser = argparse .ArgumentParser ( 934description = ( 935"Test llama-server parallel tool-calling capability. Run this only " 936"against models configured for parallel tool calls — this script " 937"does not configure that itself." 938 ) 939 ) 940parser .add_argument ("--host" ,default = "localhost" ) 941parser .add_argument ("--port" ,default = 8080 ,type = int ) 942parser .add_argument ( 943"--no-stream" ,action = "store_true" ,help = "Disable streaming mode tests" 944 ) 945parser .add_argument ( 946"--stream-only" ,action = "store_true" ,help = "Only run streaming mode tests" 947 ) 948parser .add_argument ( 949"--test" , 950help = "Run only the test whose name contains this substring (case-insensitive)" , 951 ) 952args = parser .parse_args () 953 954url = f"http:// { args . host } : { args . port } /v1/chat/completions" 955print_info (f"Testing server at { url } " ) 956print_warn ( 957"This script expects the target model to emit multiple tool calls in a " 958"single assistant turn. Run it only against parallel-tool-capable models." 959 ) 960 961modes :list [bool ]= [] 962if not args .stream_only : 963modes .append (False ) 964if not args .no_stream : 965modes .append (True ) 966 967cases :list [dict ]= ALL_TEST_CASES 968if args .test : 969name_filter = args .test .lower () 970cases = [c for c in cases if name_filter in str (c ["name" ]).lower ()] 971if not cases : 972print_fail (f"No test cases matched ' { args . test } '" ) 973sys .exit (1 ) 974 975total = 0 976passed = 0 977for stream in modes : 978for case in cases : 979total += 1 980if run_test (url ,case ,stream = stream ): 981passed += 1 982 983color = GREEN if passed == total else RED 984_print (f"\n { BOLD } { color } { '─' * 60 } { RESET } " ) 985_print (f" { BOLD } { color } Results: { passed } / { total } passed { RESET } " ) 986_print (f" { BOLD } { color } { '─' * 60 } { RESET } \n" ) 987sys .exit (0 if passed == total else 1 ) 988 989 990if __name__ == "__main__" : 991main ()