A reliable DeepSeek tool loop is an application state machine: receive a complete proposal, validate it, execute only an allowed function, attach its result to the correct call ID, and stop under explicit limits. The model chooses what to propose. Your program decides what actually happens.
This tutorial builds that boundary with two harmless Python tools: a fictional stock lookup and a length converter. It concentrates on transcript correctness, replay identity, completion states, and failure handling. You will get an executable offline example, a separate API adapter template, and a practical plan for the parts that need stronger infrastructure before you connect a tool that changes anything.
Verification note: The API details were checked against official DeepSeek documentation on October 2, 2026. The Python tests use local fixtures and arithmetic; they do not call DeepSeek. The API adapter is an unexecuted integration template. No live-model quality, latency, reliability, or cost benchmark is claimed.
What deterministic means in a tool-calling application
Consider a customer asking whether an item is available and how a measurement converts into another unit. An assistant can propose a catalog lookup and a calculation. Even when both proposals look sensible, several things remain undecided: whether the arguments belong to the advertised schema, whether the caller may access that catalog, whether either request has already run, and whether there is enough time left to return a useful answer. Those decisions belong in application code.
In this guide, deterministic means that the same accepted application state and the same recorded proposal produce the same policy decision. A malformed argument object is rejected every time. A reused identifier with changed input stops every time. An exhausted call budget prevents another execution every time. These properties are inspectable without asking a model to judge whether it followed instructions.
It does not mean that a model generates identical text across runs. It does not mean a remote service executes exactly once. It does not turn a language model into a transaction coordinator. Keeping these meanings separate makes the system easier to test and keeps the article’s claims narrower than the implementation actually supports.
The demonstration tools are intentionally read-only and local. One reads a constant dictionary; the other computes a conversion. They need no account, network, API key, payment method, filesystem mutation, or external permission. A production executor might send an email or reserve inventory, but that would add authorization, durable operation tracking, reconciliation, and a much more demanding failure model.
If you need basic client configuration first, start with the DeepSeek API implementation guide. If your main problem is hostile content changing an agent’s behavior, the separate prompt-injection prevention guide covers that trust boundary. Here, the central question is whether your program can explain the exact state of every proposed call.
Start from the current DeepSeek contract
The current documentation recommends deepseek-flash and deepseek-v4-pro. This example uses deepseek-flash with the OpenAI-format base URL https://api.deepseek.com and Chat Completions. The ordinary generation route is /chat/completions. Recheck the official first-call and API references before adopting these settings in a long-lived integration.
A function definition describes an available capability and its argument schema. It does not upload your Python implementation. DeepSeek can return an assistant message containing one or more function proposals. Your application implements dispatch, performs the work, and sends the results back in tool messages. The result’s tool_call_id identifies which proposal it answers.
Ordinary tool arguments arrive as JSON text and need application validation. A plausible-looking function name or a successfully parsed object is insufficient. The executor below checks its own allowlist and argument rules regardless of what the model was asked to do.
The integration template explicitly disables thinking mode and sets tool_choice="auto". Thinking is currently enabled by default, so omission would change the example’s conversation requirements. This is a teaching choice, not a claim that non-thinking mode is better for every tool task. The thinking-mode evaluation guide explains how to choose modes using task evidence.
Keep provider-specific request construction in a small adapter. The executor should not need to know the SDK’s object classes, HTTP headers, or billing fields. That separation lets you replay recorded responses against the same local code and makes it easier to identify whether a failure comes from transport, protocol, policy, or the tool itself.
Treat the transcript as a protocol record
A tool round has three important pieces: the assistant’s proposal, the application’s corresponding result messages, and the next assistant response. Preserve that sequence. Sending a result without the assistant message that introduced its call ID loses the relationship the model needs. Sending the assistant proposal while dropping one of its results leaves the round incomplete.
Suppose the assistant proposes calls with IDs stock_1 and length_1. The first requests the fictional item A100; the second converts 2.5 metres to centimetres. The application appends the assistant message, then a tool message with tool_call_id="stock_1", then another with tool_call_id="length_1". Only after both results are present does it ask for the next completion.
The order in which two independent tools finish is not their identity. Do not match outputs by array position after concurrent work has reordered them. Associate each result with its original ID as soon as dispatch starts. The sample executes sequentially and preserves proposal order, which is easier to inspect and sufficient for these tiny local functions.
A failure is still a result when the call can be identified and the failure is expected. An unknown function can receive a structured UNKNOWN_TOOL result. Invalid arguments can receive INVALID_ARGUMENTS. An absent call ID is different: the application cannot form a properly correlated reply, so the sample stops with a protocol error rather than inventing an identifier.
Do not manufacture model tool calls to insert into a live Chat Completions history. The current Tool Calls guide distinguishes that operation from replying to actual model-generated calls and says Chat Completions does not support the former. The fixture IDs below belong only to an offline simulator. They demonstrate application behavior and are not sent to DeepSeek.
Store the accepted assistant message before dispatching a production operation, together with the task identity and the planned calls. Otherwise a process crash can leave you with a completed external action and no durable record of why it happened. The compact sample keeps everything in memory, so it illustrates message ordering rather than crash-safe persistence.

Define narrow tools with explicit outputs
The most useful tool interface describes a small operation with a bounded result. Our stock lookup accepts one SKU from a two-item fictional catalog. Our converter accepts a value and two unit names. Neither accepts executable code, a URL, a SQL query, a shell command, or an arbitrary destination. A narrow interface reduces how much the dispatcher has to interpret.
The stock result includes its data origin. Seven available A100 items are a fixture value, not current warehouse inventory. B200 has zero available items, which is a valid answer rather than an execution error. This distinction matters: a model should not retry a successful zero merely because the user hoped for a positive result.
The conversion result uses a decimal string, with compact exponent notation for extremely small values. For example, a successful conversion can return {"value":"250","unit":"cm"}. The string is deliberate: it avoids pretending that every decimal measurement has an exact binary floating-point representation. The tool contract should tell the answer formatter how to display that value instead of silently changing its type later.
The input object rejects extra fields. A caller cannot add a hidden warehouse selector or a different conversion mode and expect the program to honor it. In a real multi-tenant application, a tenant or account identifier should come from authenticated server context when possible, not from a model argument that can point at someone else’s account.
Here is the advertised schema. Save each named code block as the filename shown, in the same directory. The schema deliberately stays small; the actual functions also validate their inputs. For a deeper explanation of JSON syntax, schemas, and semantic correctness, see the structured-output guide.
tool_schema.py · Offline Python example
"""Schema is advisory on the normal endpoint; local validation is authoritative."""
TOOLS = [
{"type": "function", "function": {
"name": "lookup_stock",
"description": "Read availability from a fictional offline catalog.",
"parameters": {"type": "object", "properties": {
"sku": {"type": "string", "enum": ["A100", "B200"]}},
"required": ["sku"], "additionalProperties": False}}},
{"type": "function", "function": {
"name": "convert_length",
"description": "Convert a nonnegative length no greater than 100000.",
"parameters": {"type": "object", "properties": {
"value": {"type": "number"},
"from_unit": {"type": "string", "enum": ["mm", "cm", "m"]},
"to_unit": {"type": "string", "enum": ["mm", "cm", "m"]}},
"required": ["value", "from_unit", "to_unit"],
"additionalProperties": False}}}
]
Build an executor that fails predictably
The executor performs a preflight pass before running any tool in a batch. It checks that the batch is bounded, every call has a usable ID, every proposal is a function call, and no two calls in the same batch reuse an ID. It also checks the task-local ledger for a conflicting replay identity. A structural failure in the second proposal therefore cannot appear only after the first tool has already run.
Here, structural rejection means an invalid call envelope, duplicate ID, or conflicting replay. Malformed JSON and invalid function arguments produce per-call errors, so an independent valid sibling can still execute. This is not an all-or-nothing transaction across the entire batch. A structurally valid proposal with bad business arguments gets a normal error result, while another valid proposal can succeed. That behavior is appropriate for two independent read-only requests. A business workflow that requires atomicity needs a domain transaction; it should not infer atomicity from a loop over JSON objects.
JSON parsing is stricter than simply calling the default decoder and hoping for a dictionary. The implementation rejects duplicate object keys, nonstandard constants, non-object top-level values, oversized argument strings, and values that violate each function’s rules. Python’s default JSON behavior has edge cases that are useful for interoperability but undesirable at an execution boundary. The explicit parser choices make those cases testable.
Numeric handling is part of that boundary. A boolean is not a permitted length even though Python treats booleans as a kind of integer. Non-finite values are not useful measurements. Values beyond the advertised local limit are rejected. The conversion path retains decimal precision and applies power-of-ten unit changes without borrowing a process-wide rounding policy.
The replay ledger is intentionally conservative. It binds a call ID to the function name and the exact argument text. An identical replay can reuse the stored result. Changing whitespace or numeric spelling under the same ID is treated as changed input, even if a person believes the JSON means the same thing. This avoids silently merging distinguishable inputs and keeps the demo’s rule easy to audit.
Expected validation problems become short, controlled error objects. They do not expose a Python traceback, environment variable, file path, or server response body. Unexpected programming errors are not disguised as successful tool results. In a deployed service, catch such failures at an outer boundary, write an appropriately protected diagnostic record, and return a clear incomplete-task status to the caller.
tool_core.py · Offline Python example
"""Offline, single-process teaching example: no network and no side effects."""
import json
from copy import deepcopy
from decimal import Decimal, DecimalException
class ProtocolError(Exception):
pass
def reject_constant(value):
raise ValueError("non-finite JSON constant")
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key")
result[key] = value
return result
def error(code, message):
return {"ok": False, "error": {"code": code, "message": message}}
def parse_arguments(raw):
if not isinstance(raw, str) or len(raw.encode("utf-8")) > 4096:
raise ValueError("arguments must be a short JSON string")
args = json.loads(raw, parse_float=Decimal, parse_constant=reject_constant,
object_pairs_hook=unique_object)
if not isinstance(args, dict):
raise ValueError("arguments must be an object")
return args
def lookup_stock(args):
if (set(args) != {"sku"} or not isinstance(args["sku"], str)
or args["sku"] not in ("A100", "B200")):
raise ValueError("sku must be A100 or B200; no other fields")
return {"sku": args["sku"], "available": {"A100": 7, "B200": 0}[args["sku"]],
"source": "fictional offline catalog"}
def convert_length(args):
if set(args) != {"value", "from_unit", "to_unit"}:
raise ValueError("expected value, from_unit, and to_unit only")
value = args["value"]
if type(value) not in (int, float, Decimal):
raise ValueError("value must be a number, not a boolean")
value = Decimal(str(value))
if not value.is_finite() or not Decimal(0) <= value <= Decimal(100000):
raise ValueError("value must be finite and between 0 and 100000")
powers = {"mm": 0, "cm": 1, "m": 3}
if any(not isinstance(args[key], str) or args[key] not in powers
for key in ("from_unit", "to_unit")):
raise ValueError("units must be mm, cm, or m")
# Adjust the decimal exponent exactly; do not depend on ambient precision.
parts = value.as_tuple()
shift = powers[args["from_unit"]] - powers[args["to_unit"]]
result = Decimal((parts.sign, parts.digits, parts.exponent + shift))
# Tiny exponents use compact notation rather than allocating huge strings.
style = "f" if abs(result.as_tuple().exponent) <= 4096 else "E"
rendered = format(result, style)
if len(rendered) > 8192:
raise ValueError("result is too large")
return {"value": rendered, "unit": args["to_unit"]}
REGISTRY = {"lookup_stock": lookup_stock, "convert_length": convert_length}
def prepare(call):
if not isinstance(call, dict) or call.get("type") != "function":
raise ProtocolError("expected a function call")
call_id = call.get("id")
fn = call.get("function")
if not isinstance(call_id, str) or not 1 <= len(call_id) <= 200:
raise ProtocolError("missing or invalid call ID")
if not isinstance(fn, dict) or not isinstance(fn.get("name"), str):
raise ProtocolError("missing function name")
name, raw = fn["name"], fn.get("arguments")
try:
valid_raw = isinstance(raw, str) and len(raw.encode("utf-8")) <= 4096
except UnicodeEncodeError:
valid_raw = False
if not valid_raw:
raise ProtocolError("missing or oversized argument string")
# Conservative replay identity: only the exact same argument text may repeat.
signature = (name, raw)
try:
args = parse_arguments(raw)
failure = None
except (ValueError, RecursionError, DecimalException):
args = None
failure = error("INVALID_ARGUMENTS", "Use a finite JSON object with unique keys")
return call_id, name, args, signature, failure
class Executor:
"""Cache only within this task and process; not durable exactly-once storage."""
def __init__(self):
self.ledger = {}
self.executions = 0
def batch(self, calls):
if not isinstance(calls, list) or not 1 <= len(calls) <= 4:
raise ProtocolError("expected one to four calls")
prepared = [prepare(call) for call in calls]
ids = [item[0] for item in prepared]
if len(set(ids)) != len(ids):
raise ProtocolError("duplicate call ID in batch")
for call_id, _, _, signature, _ in prepared:
if call_id in self.ledger and self.ledger[call_id][0] != signature:
raise ProtocolError("call ID reused for different input")
messages = []
for call_id, name, args, signature, failure in prepared:
if call_id in self.ledger:
result = deepcopy(self.ledger[call_id][1])
else:
result = failure
if result is None and name not in REGISTRY:
result = error("UNKNOWN_TOOL", "Choose an advertised tool")
if result is None:
try:
data = REGISTRY[name](args)
result = {"ok": True, "data": data}
self.executions += 1
except (ValueError, TypeError, DecimalException):
result = error("INVALID_ARGUMENTS", "Arguments failed local validation")
self.ledger[call_id] = (signature, deepcopy(result))
messages.append({"role": "tool", "tool_call_id": call_id,
"content": json.dumps(result, allow_nan=False)})
return messages
Bound the loop before requesting another completion
The loop below accepts a completion function as an argument. In the offline run, that function returns a sequence of prepared fixtures. In a later integration, the function can call the SDK. Everything after that boundary uses ordinary dictionaries, which makes the execution policy independent of a live model.
The loop distinguishes a completed final answer from a completed tool proposal. A final answer needs a normal stopping state, no tool calls, and nonempty answer text. A tool round needs the tool-call stopping state and a nonempty list of proposals. A truncated response, a filtered response, an aborted response, or an inconsistent combination is not an executable plan.
There are two separate quantity budgets: the number of completion requests and the total number of proposed tool calls. A single completion can contain multiple proposals, so a round limit alone does not bound tool work. The executor also caps the size of one batch. These constants are tutorial settings, not recommendations for every workload.
The loop refuses to execute a tool if it has already reached the last permitted completion request. That leaves no request budget to give the result back to the model. For a read-only toy this mainly prevents wasted work; for a consequential operation it helps prevent an application from performing an action that it cannot coherently finish reporting.
Quantity limits are not timeouts. The injected adapter has a network timeout, but the pure functions do not need an asynchronous cancellation mechanism. A real slow tool requires its own deadline, isolation boundary, and outcome handling. Wrapping a blocking write in a timed wait does not cancel the remote operation or tell you whether it completed.
The loop copies input messages before changing them. Tests can therefore assert that a caller’s original conversation remains untouched. It returns the full completed transcript along with the final text and counters, giving the application evidence to inspect. A user interface may show only the answer, but the application should not discard the operation record before it has fulfilled its own retention and privacy policy.
tool_loop.py · Offline Python example
"""Bounded non-streaming, non-thinking loop with an injected completion function."""
from copy import deepcopy
from tool_core import Executor, ProtocolError
def run_loop(complete, initial_messages, tools, max_requests=6, max_calls=8):
if type(max_requests) is not int or not 1 <= max_requests <= 20:
raise ValueError("max_requests must be an integer from 1 to 20")
if type(max_calls) is not int or not 1 <= max_calls <= 100:
raise ValueError("max_calls must be an integer from 1 to 100")
messages = deepcopy(initial_messages)
executor, calls_used = Executor(), 0
for request_index in range(max_requests):
# The injected adapter must also enforce a network timeout.
reply = complete(deepcopy(messages), deepcopy(tools))
if not isinstance(reply, dict):
raise ProtocolError("expected a completion object")
message = reply.get("message")
finish = reply.get("finish_reason")
if not isinstance(message, dict) or message.get("role") != "assistant":
raise ProtocolError("expected an assistant message")
if message.get("content") is not None and not isinstance(message["content"], str):
raise ProtocolError("assistant content must be text or null")
calls = message.get("tool_calls")
if calls is None:
calls = []
if not isinstance(calls, list):
raise ProtocolError("tool_calls must be a list")
if finish == "tool_calls" and calls:
if calls_used + len(calls) > max_calls:
raise ProtocolError("total tool-call budget exhausted")
if request_index == max_requests - 1:
raise ProtocolError("no request budget left for tool results")
outputs = executor.batch(calls)
messages.append(deepcopy(message))
messages.extend(outputs)
calls_used += len(calls)
elif finish == "stop" and not calls:
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise ProtocolError("empty final answer")
messages.append(deepcopy(message))
return {"answer": content, "messages": messages,
"requests": request_index + 1, "calls": calls_used,
"executions": executor.executions}
else:
raise ProtocolError("incomplete or inconsistent completion")
raise ProtocolError("request budget exhausted")
Run the offline example and inspect the evidence
Save this file beside the previous three and run python offline_demo.py with a compatible Python interpreter. The demonstration uses only the standard library. It does not import the SDK adapter, read a credential, or open a network connection. Its final answer is a written fixture, not text generated by DeepSeek.
offline_demo.py · Offline Python example
"""Run: python offline_demo.py. Uses fixtures, not a model or API."""
import json
from tool_loop import run_loop
from tool_schema import TOOLS
def call(call_id, name, args):
return {"id": call_id, "type": "function", "function": {
"name": name, "arguments": json.dumps(args)}}
REPLIES = [
{"finish_reason": "tool_calls", "message": {
"role": "assistant", "content": None, "tool_calls": [
call("fixture_stock", "lookup_stock", {"sku": "A100"}),
call("fixture_length", "convert_length", {
"value": 2.5, "from_unit": "m", "to_unit": "cm"})]}},
{"finish_reason": "stop", "message": {
"role": "assistant", "content":
"The fictional catalog has 7 A100 items. 2.5 m equals 250 cm."}}
]
def main():
fixtures = iter(REPLIES)
result = run_loop(lambda messages, tools: next(fixtures), [
{"role": "user", "content": "Check A100 stock and convert 2.5 m to cm."}
], TOOLS)
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
The arithmetic is straightforward: one metre is 100 centimetres, so 2.5 metres is 250 centimetres. The catalog lookup returns seven A100 items because that value is defined in the local fixture. The transcript should contain the user message, one assistant message proposing two tools, two correlated tool results, and one final assistant message.
The returned counters should show two completion-function invocations, two tool proposals, and two successful local executions. Those numbers describe the simulator. They say nothing about how many calls a live model would choose, how long the API would take, or whether a live answer would quote the results faithfully.
Inspect both tool messages rather than checking only the printed final sentence. A fixture can contain an attractive answer even if the application supplied the wrong result. Assert that the IDs match the proposals, the stock result is successful, the conversion unit is centimetres, and the numerical value is exactly equivalent to 250. This is the difference between testing a transcript and testing a screenshot of an answer.
Next, change A100 to B200 in a fresh fixture and change the fixture answer accordingly. A successful zero-stock result should stay successful. Then use an unsupported SKU or add an unexpected field. That should produce a validation error rather than changing the catalog or falling back to an approximate match. Keep these cases separate so you can tell legitimate negative data from failed execution.
Connect the API only after the local boundary is tested
The following adapter is a template for a later authorized integration. It was not executed for this article. A real invocation sends the conversation and tool definitions to DeepSeek and can incur API charges. Keep credentials out of source files and tool arguments. Configure them through your deployment’s approved secret mechanism.
The adapter turns off automatic SDK retries in this teaching example, uses an explicit network timeout, and requests a bounded non-streaming completion. Those choices make attempts easier to count while you are learning the protocol. They do not amount to a complete production retry policy, and the timeout is not a guarantee that an entire multi-step task finishes within that duration.
Install and pin an SDK version in your own project, then test its actual request serialization and response handling. A method name that looks compatible does not establish every optional parameter or streaming behavior. Keep the API adapter in a separate file so running the offline demonstration cannot accidentally invoke the network.
deepseek_adapter_NOT_EXECUTED.py · API template, not executed
"""API template only: not run for this article. Requires the OpenAI Python SDK.
A real invocation sends data to DeepSeek and can incur API charges.
"""
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["DEEPSEEK_API_KEY"],
base_url="https://api.deepseek.com",
timeout=30.0, max_retries=0)
def complete(messages, tools):
response = client.chat.completions.create(
model="deepseek-flash", messages=messages, tools=tools,
tool_choice="auto", stream=False, max_tokens=2048,
extra_body={"thinking": {"type": "disabled"}})
choice = response.choices[0]
message = choice.message.model_dump(exclude_none=True,
include={"role", "content", "tool_calls"})
message.setdefault("content", None)
return {"finish_reason": choice.finish_reason, "message": message}
To integrate it, pass its complete function to run_loop instead of the fixture callback. Start with synthetic, non-sensitive prompts and the two read-only tools. Verify the raw returned message shape, terminal reason, and matched result IDs before adding richer tools. Record the resolved model configuration and SDK version with the integration test results.
The adapter intentionally preserves only the fields required by this non-thinking example. If you enable thinking, this narrow serializer must change. Do not toggle the mode flag while leaving a serializer that drops reasoning history in place. A configuration change that alters the protocol belongs in code review and a regression test.
Treat strict schemas and thinking history as separate concerns
DeepSeek’s strict tool mode is currently a beta feature. Its guide specifies the beta base URL, strict: true on every function, all object properties listed as required, and additional properties disabled. These requirements apply recursively to nested objects. The supported schema subset is narrower than every feature you might know from general JSON Schema.
Our sample uses the ordinary endpoint and local checks. Moving it to strict mode is a deliberate integration change, not a reason to delete those checks. Strict generation cannot establish that a user may access a record, that a requested action was approved, or that a proposed value is correct for the business context. Server-side generation constraints and application execution policy answer different questions.
Thinking mode changes history handling. The current dedicated guide says that when tools are included, previous assistant reasoning_content must be carried forward, including turns without tool calls. Missing that state can produce an HTTP 400 response. Preserve the provider-required message fields rather than rebuilding history from only what your chat interface displays.
The API reference also says required or named tool choices are not supported while thinking is enabled. This tutorial avoids that combination. If your workflow depends on forcing a particular function, choose a supported mode and test the request; do not assume that a parameter accepted by another provider behaves identically here.
Keep required model history separate from a user-visible answer and from general diagnostic logs. A field needed for protocol continuity is not automatically appropriate for broad logging, analytics, or display. Define who can access stored conversations, how long they persist, and how deletions propagate before treating a serialized message archive as an operational convenience.
Separate replay identity from business idempotency
The tutorial’s ledger solves a deliberately small problem: within one task and one process, the same recorded call can return its saved result without running the local function again. Its scope ends when the process ends. It is not shared between workers, does not survive a crash, and does not coordinate with an external service.
A call ID is a protocol correlation identifier. A business operation ID identifies the real-world action your application intends to perform. They may be related, but they are not interchangeable. If a model proposes the same email twice with two different call IDs, the protocol has two proposals. Whether the second is an accidental repeat depends on user intent and application state, not merely on equal text.
Conversely, identical arguments can describe two intentional operations. A user can legitimately ask to create two identical labels or schedule two similar jobs. Automatically collapsing every matching argument object would lose an intended action. Establish the business action identity when the user or workflow authorizes the operation, and carry that identity through the executor independently of model-generated correlation IDs.
For a tool that changes external state, a more complete design stores the operation identity, an immutable payload digest, relevant authorization context, status, timestamps, and any provider operation identifier. A unique database constraint or equivalent concurrency control prevents two workers from claiming the same operation without coordination. The remote provider may also support an idempotency key, but its documented scope and retention window still matter.
Consider a worker that sends a write request and crashes before saving the response. When it restarts, the local record might still say the operation was in progress. Replaying immediately could duplicate the write. Marking it failed could be equally wrong if the provider completed it. The application needs a reconciliation route that asks the provider what happened or compares an authoritative operation record.
A durable outbox can help connect an application transaction to dispatch intent. It does not, by itself, prove exactly-once remote execution. The dangerous gap is between an external side effect and the record of that effect. Design that gap explicitly, including what happens if the provider offers neither an idempotency key nor a reliable status lookup.
The sample’s replay tests operate directly on the executor and the same recorded batch. They do not claim that a live API accepts arbitrary duplicate call IDs inserted into successive assistant turns. Keep the replay layer below transcript assembly: reuse a known result to finish the existing recorded operation, rather than inventing a second historical proposal.

Handle failures according to what is known
A useful error policy starts with the question, “What do we know happened?” Invalid arguments mean the tool did not execute. A successful zero-stock lookup means the tool executed and returned a valid negative answer. A remote timeout after dispatch may mean the outcome is unknown. Combining these states into one generic failure flag makes safe recovery much harder.
For invalid input, return a concise machine-readable code and let the model propose a corrected call within the remaining budget. Do not patch arbitrary arguments until they look acceptable. Silently changing a recipient, amount, path, or identifier can create a different operation from the one that was requested. Even harmless normalization should be specified in the tool contract and covered by tests.
For an unknown tool name, refuse dispatch through the fixed registry. Never resolve the name with eval, dynamic imports, unrestricted attribute access, or a shell. The model’s description of a function does not install it. If the capability is unavailable, return that fact and let the surrounding application decide whether the task can still be completed.
For a transport failure before a completed model response has been accepted, the application may decide to request another completion under a separate retry budget. However, it should not claim the provider did no work, incurred no cost, or produced no response merely because the client timed out. Count attempts, preserve useful request identifiers, and distinguish delivery uncertainty from a definite rejection.
For a completed tool proposal followed by an external read failure, a bounded retry can be appropriate if repeating the read is permitted and useful. For a write with an uncertain outcome, reconcile first. The language model should not choose whether to repeat an irreversible operation solely because it sees an error message. That decision belongs to a policy that knows the operation identity and provider semantics.
Authentication errors, invalid request formats, and unsupported parameter combinations should not trigger a rapid retry loop with the same inputs. Fix configuration or report the blocker. Rate limits and temporary service problems require a different policy, with bounded delay, jitter where appropriate, and a total task deadline. Honor provider retry guidance where available rather than inventing an unlimited waiting strategy.
Finally, give the user an accurate end state. “The lookup failed” differs from “The reservation may have completed, but confirmation is unavailable.” The second requires care because retrying may create another reservation. Honest uncertainty is useful operational information; hiding it behind a confident final sentence does not make the workflow reliable.
Add streaming only after complete-message execution works
Streaming is a presentation and transport change, not permission to execute an unfinished proposal. A partial argument fragment can be valid-looking JSON and still be only the beginning of the intended object. Keep accumulating the stream until the provider has delivered a complete, acceptable tool-call response. An interrupted stream is not a completed instruction.
For DeepSeek Chat Completions, aggregate tool-call fragments by their call index and retain the identifying fields from the initial fragment. Do not concatenate every argument fragment in the response into one string; a response can describe multiple tools. Validate each assembled call with the same executor boundary used by the non-streaming path.
The current API reference places usage in the final chunk with a non-null finish reason, then ends the SSE stream with [DONE]. It does not describe a separate usage-only chunk. A parser copied from another service’s example may therefore make the wrong assumption about when usage or completion evidence becomes available.
Test stream assembly independently with synthetic fragments. Split an argument string inside an escaped quote, between digits, and between object fields. Interleave two call indices. End the stream early. Your assembler should either reproduce the intended completed response or return an incomplete status. It should never dispatch a tool while the test is still feeding fragments.
This article’s executable loop remains non-streaming. That keeps the tested claim precise. A future streaming adapter needs its own tests for parsing, maximum buffered bytes, cancellation, terminal-state interpretation, and reconnect behavior before it can reuse the executor with confidence.
Set budgets and collect evidence that answers real questions
Use separate limits for completion attempts, tool proposals, concurrently running tools, argument bytes, result bytes, elapsed time, and retained conversation size. Each protects a different resource. A short argument can return a huge document. A single model round can request several tools. A low call count can still hide one very slow operation.
The sample enforces request, call, batch, and argument limits and keeps tool outputs inherently small. It does not implement a production transport body limit, durable storage quota, tenant-level rate limiting, or a hard tool deadline. Adding these explicitly is more honest than calling a compact teaching loop production-ready because it has one counter.
Measure time at meaningful boundaries: request started, completed proposal received, dispatch started, tool outcome known, and final answer returned. A fast first token does not show that a tool finished. A short model response does not show that the overall task was cheap. Keep model time, queue time, tool time, and reconciliation time distinct when investigating delays.
Useful records include a task ID, the relevant call IDs, tool name, schema or tool version, validation decision, result status, whether the result was replayed, attempt count, and terminal reason. Avoid recording raw secrets or entire sensitive tool responses merely because they are easy to serialize. When detailed content is necessary, use appropriate access controls and retention rules.
Give status counters denominators. “Three tool errors” has little meaning without knowing whether the application attempted five calls or five thousand. Track invalid proposals separately from tool-service failures and from answers that misrepresent successful results. A model can ask the correct tool and still write an incorrect summary; execution success is not answer correctness.
When you change a schema, version it with the implementation that interprets it. A replay of an old recorded call should not silently run under a newly incompatible function. Decide whether to retain the old interpreter, migrate the operation under controlled rules, or reject the stale request. A deployment timestamp is not a substitute for an explicit compatibility policy.
Test failure paths before using a live model
A good local suite should fail if the execution boundary becomes more permissive by accident. Test malformed JSON, duplicate keys, non-finite numbers, excessive nesting, incorrect top-level types, extra properties, boolean measurements, unsupported units, out-of-range values, and oversized or invalidly encoded argument text. These are execution tests, not a prompt benchmark.
Test correlation separately. Reject missing IDs and duplicate IDs inside one batch. Replay the same recorded call and verify that the saved result returns without another execution. Change the tool name or argument text under an existing ID and require a protocol error. Use a new ID with the same arguments and verify that it remains a new proposal.
Test preflight behavior with a valid first call and a structurally invalid second call. No tool should execute before the structural rejection. Then test a valid first call with a second call that has ordinary invalid business arguments. The expected result is one success and one controlled error, because this demonstration is not an atomic multi-tool transaction.
Test the loop with a final answer that needs no tools, one tool round, multiple independent calls, repeated rounds using fresh IDs, an empty answer, inconsistent terminal states, and every exhausted budget. Test that the final permitted request does not dispatch work with no continuation budget. Verify the original input history is unchanged.
For the converter, check exact representative cases and generate additional values with an independent arithmetic oracle. Include metre-to-centimetre, centimetre-to-millimetre, reverse conversions, zero, the maximum accepted input, and values just outside the limit. Test under an altered decimal context so unrelated code cannot change the conversion’s correctness.
These offline checks establish behavior for the implementation and fixtures they exercise. They do not prove that every live model response matches the adapter, that a provider will always be available, or that a final natural-language answer is faithful. Add a small separately labeled live integration suite before deployment, and retain the local suite so a network outage does not prevent testing your own logic.
A practical release checklist
Before connecting a new tool, write down what success means, what information the tool may access, whether it changes external state, and how an uncertain outcome will be resolved. If those answers are missing, a better tool description will not fill the gap. The executable capability needs an operational contract as well as a JSON schema.
- Confirm the actual model ID, mode, endpoint, SDK version, and supported request parameters
- Validate completed responses and bind every result to its original call ID
- Keep dispatch allowlisted and authorization in application-controlled context
- Enforce independent work, size, time, and retry budgets
- Distinguish successful negative data, rejected input, definite failure, and unknown outcome
- Use durable business operation identities and reconciliation before enabling writes
- Test the adapter separately from the pure executor and any streaming assembler
- Verify that final answers accurately describe tool results and incomplete work
Start with the local fixture, inspect the transcript, and make the failure cases boring. Once the application can reliably say what it accepted, what it ran, what it knows, and why it stopped, a model has a much clearer role: proposing useful next steps inside a boundary your software can actually enforce.
Sources & References
Primary references checked October 2, 2026. These links support provider and Python behavior; the architecture, examples, and operational recommendations are the author’s implementation guidance.
- DeepSeek API documentation: first call and current model identifiers
- DeepSeek Chat Completions API: messages, tool calls, terminal reasons, and streaming
- DeepSeek Tool Calls guide: function execution and strict-mode requirements
- DeepSeek Thinking Mode guide: tool history and reasoning-content continuity
- DeepSeek error codes and documented recovery guidance
- Python JSON documentation: decoding hooks and nonstandard numeric values
- Python decimal documentation: exact decimal representation and arithmetic contexts

[…] active tool cycle and test the provider-specific history rules before compacting older cycles. The DeepSeek function-calling guide explains the execution loop […]