Agent and MCP guides · 2026-08-28
Building a tool calling loop with muse-spark-1.2-contributor
Build a controlled Muse Spark Contributor tool loop through LLMTR, with auto tool selection, an allowlist, JSON argument validation and bounded execution.
The model selects a tool; the application authorizes execution
Build a muse-spark-1.2-contributor tool loop through LLMTR using meta/muse-spark-1.2-contributor and POST /v1/chat/completions. Send tool definitions, inspect returned calls, execute permitted functions and return their results in a subsequent model request. Generating a function name does not grant the model permission to execute it.
Muse accepts only tool_choice: auto, or you can omit the field. LLMTR rejects required, none and named selection. If completing a task requires an actual tool result, enforce that condition in your application. Asking the model to use a tool is not an execution guarantee.
Set the Contributor data boundary first
Under the Contributor tier, prompts and completions may be used for Meta model training. Do not submit confidential, personal or customer data. This warning also applies to tool results: once appended to the conversation, they become part of the next model request.
The example therefore converts centimeters to meters without accounts, customer records, file paths or external services. Even a public document can contain email addresses; public accessibility alone is not a sufficient data check. In a real integration, restrict tool output to the fields needed before forwarding it to the model.
Preserve the correct conversation messages
function.arguments contains JSON text. Parse it, then validate field names, types and permitted ranges. The tool definition’s schema does not replace application validation. Python’s JSON documentation also recommends limiting untrusted input size before parsing.
Branch on tool_calls. When calls exist, append the returned assistant message, followed by a separate tool message for every call using the same tool_call_id. The result content must be serialized text, not a JSON object.
| Situation | Application check | Failure behavior |
|---|---|---|
| Unknown function | Fixed tool allowlist | Stop before execution |
| Unexpected argument | Fields, types, finiteness and range validation | Reject the call |
| Repeated call identifier | Track identifiers across the conversation | Do not process the identifier again |
| Too many tool requests | Maximum turns and calls per turn | Stop with a budget error |
| Missing or truncated response | Check content and finish reason | Do not mark the task complete |
A bounded Python loop without tool side effects
Set LLMTR_BASE_URL to the complete API base address verified for your account, including /v1, and LLMTR_API_KEY to your LLMTR key. These are not direct Meta credentials. The code appends only /chat/completions; it does not add a second /v1.
This instructional example can make billable model requests when executed; it was not run live here. It allows at most three model requests, with two local tool operations per turn during the first two turns. The final turn omits tools and tool_choice. The 4096 token budget and 45 second timeout are example application settings, not guaranteed model limits. The answer variable holds the result without logging its contents.
Instructional example using only a nonsensitive unit conversion
import json
import math
import os
from urllib.request import Request, urlopen
TOOL = {
"type": "function",
"function": {
"name": "cm_to_m",
"description": "Convert a nonnegative length from centimeters to meters.",
"parameters": {
"type": "object",
"properties": {"cm": {"type": "number", "minimum": 0, "maximum": 100000}},
"required": ["cm"],
"additionalProperties": False,
},
},
}
def execute(call):
if call.get("type") != "function":
raise ValueError("Unsupported tool type")
function = call["function"]
if function["name"] not in {"cm_to_m"}:
raise ValueError("Tool not allowed")
raw = function["arguments"]
if not isinstance(raw, str) or len(raw) > 256:
raise ValueError("Invalid argument size")
args = json.loads(raw)
if type(args) is not dict or set(args) != {"cm"}:
raise ValueError("Invalid argument fields")
value = args["cm"]
if type(value) not in (int, float) or not math.isfinite(value):
raise ValueError("A finite number is required")
if not 0 <= value <= 100000:
raise ValueError("Length outside permitted range")
return {"meters": value / 100}
def run():
endpoint = os.environ["LLMTR_BASE_URL"].rstrip("/") + "/chat/completions"
messages = [{"role": "user", "content": "Convert 250 centimeters to meters using cm_to_m and explain briefly."}]
seen_ids = set()
for turn in range(3):
payload = {
"model": "meta/muse-spark-1.2-contributor",
"messages": messages, "stream": False,
"reasoning_effort": "minimal", "max_tokens": 4096,
}
if turn < 2:
payload.update(tools=[TOOL], tool_choice="auto")
request = Request(endpoint, data=json.dumps(payload).encode("utf-8"),
headers={"Authorization": "Bearer " + os.environ["LLMTR_API_KEY"],
"Content-Type": "application/json"}, method="POST")
with urlopen(request, timeout=45) as response:
raw = response.read(1_000_001)
if len(raw) > 1_000_000:
raise RuntimeError("Response too large")
choice = json.loads(raw)["choices"][0]
message = choice["message"]
calls = message.get("tool_calls") or []
if choice.get("finish_reason") == "length":
raise RuntimeError("Incomplete response")
if not calls:
text = message.get("content")
if not isinstance(text, str) or not text.strip():
raise RuntimeError("No final text")
return text
if turn == 2 or len(calls) > 2:
raise RuntimeError("Tool budget exhausted")
ids = [call["id"] for call in calls]
if any(not isinstance(i, str) or not 1 <= len(i) <= 256 for i in ids):
raise ValueError("Invalid call id")
if len(set(ids)) != len(ids) or seen_ids.intersection(ids):
raise ValueError("Repeated call id")
results = [execute(call) for call in calls]
seen_ids.update(ids)
messages.append(message)
for call_id, result in zip(ids, results):
messages.append({"role": "tool", "tool_call_id": call_id,
"content": json.dumps(result, allow_nan=False)})
raise RuntimeError("Turn budget exhausted")
if __name__ == "__main__":
answer = run()
Classify invalid calls before adding retries
Invalid JSON, additional fields, numeric strings, booleans, nonfinite numbers and values outside the allowed range stop execution. Unknown names never trigger dynamic function lookup. The tool writes no files and executes no shell commands; do not add those powers through model arguments.
HTTP errors also stop this example. Repeating an unchanged invalid request will not fix a 400. If you add retries for 429 responses, bound both attempts and waiting; do not launch a Contributor load test. A timeout does not establish that the previous request was never processed.
Keep these boundaries when moving into production
Complete every matching tool result before sending the next model request. If you introduce writes, add user authorization, action approval and an operation identifier that prevents repeated execution. The example’s call identifier check does not replace persistent operation deduplication.
Monitor turn counts, error categories and reported usage, without logging prompts or tool results. Each model turn is a new request, so counting only the first turn understates total consumption. Avoid carrying unnecessarily large tool results into the next request to protect both the data boundary and the conversation budget.
Frequently asked questions
What if the model answers without calling a tool?
Auto allows this. If your business rule requires tool evidence, do not treat that text as a verified result. Stop in a controlled way or perform the operation through an application defined path.
Can I disable tools with tool_choice none?
Not for Muse. Omit both tools and tool_choice from that request. Keep an application check that refuses to execute any unexpected tool call.
Does valid JSON make tool arguments safe?
No. Valid JSON can contain the wrong type, additional fields or an unauthorized operation. Use a tool allowlist together with independent application validation.