Check stock without giving an assistant a stockroom key
A customer asks, “Do we have three workshop cable kits?” You want an assistant to consult the stock list, not guess from its training or quietly create an order. This lesson builds a small read-only tool: a function that returns approved data but cannot change that data through its interface.
Input: {"tool":"stock_lookup","sku":"KIT-01","quantity":3}. Finished output: a structured result showing seven units in the supplied snapshot and enough for the requested three. A second item has zero units. A third code is unknown. Those last two situations must not collapse into the same answer.
The completed artifact is lab.py, a reusable lookup function, a request-file interface and 14 negative checks. A SKU is a stock-keeping code identifying one product. A snapshot is a captured version of the stock list, not a promise about stock right now. All products and quantities here are fictional.
This is the tools-and-integrations step in our practical agent learning path. The previous lesson checked citations in a research brief. Today we connect a request to tabular data. No earlier trading lesson or private repository is required.
Why build this boundary first?
Two public reports describe tool-input problems. A developer report about missing parameter validation, opened June 3, 2026, asks for wrong types and missing arguments to be rejected before execution. A separate OpenCode report about malformed tool arguments, opened May 24, 2026, describes missing fields and repeated failed calls. These are individual reports, not independently reproduced incidents here or a measurement of how often all agents fail.
Research question: Can we give an assistant a useful stock lookup while rejecting requests outside its narrow contract and preserving uncertainty when a code is missing?
For occasional lookups, a spreadsheet filter or this function alone is simpler than an AI agent. A model may help interpret varied customer wording, but it must still choose the correct product and quantity. This exercise tests the tool boundary, not that language understanding. It calls no model and makes no claim about model accuracy or business savings.
1. Set up a clean folder
You need Python 3.10 or newer, a terminal and a text editor. Run python --version; substitute python3 below if needed. Create an empty folder, save the complete code as lab.py, and run python lab.py there. No packages, credentials or network access are needed. Allow 25 minutes.
The embedded STOCK text is CSV, meaning comma-separated values. The first row names the columns. Python's CSV reader handles quoting and commas; splitting each line yourself would break a product name containing a quoted comma. The loader then checks the header, product codes, nonnegative counts, duplicate codes and row shape. A duplicate is rejected rather than allowing the last row to overwrite the first.
2. Run the complete tool test
The request contract permits exactly three fields. There is no filename, SQL query, shell command or reservation operation for a model to supply. Quantity must be a whole number from 1 to 50. This is a teaching limit, not a researched sales policy.
import argparse
import csv
import io
import json
import re
from pathlib import Path
# Fictional stock snapshot, not a live inventory connection.
STOCK = """sku,name,on_hand
KIT-01,Workshop cable kit,7
PAD-02,Sketch pad,0
"""
SNAPSHOT = "demo-stock-v1"
SAMPLE = {"tool": "stock_lookup", "sku": "KIT-01", "quantity": 3}
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 load_stock(text):
reader = csv.DictReader(io.StringIO(text), strict=True)
if reader.fieldnames != ["sku", "name", "on_hand"]:
raise ValueError("expected sku,name,on_hand columns in that order")
stock = {}
for row in reader:
if len(stock) >= 100 or None in row or None in row.values():
raise ValueError("too many rows or malformed row")
sku, name, count = row["sku"], row["name"], row["on_hand"]
if not re.fullmatch(r"[A-Z]{3}-[0-9]{2}", sku):
raise ValueError("invalid stock code")
if sku in stock or not name.strip() or len(name) > 80:
raise ValueError("duplicate code or invalid name")
if not re.fullmatch(r"[0-9]{1,6}", count):
raise ValueError("stock must be a nonnegative whole number")
stock[sku] = {"name": name, "on_hand": int(count)}
return stock
def lookup(request, stock):
if type(request) is not dict or set(request) != {"tool", "sku", "quantity"}:
raise ValueError("expected only tool, sku and quantity")
if request["tool"] != "stock_lookup":
raise ValueError("unknown tool")
sku, quantity = request["sku"], request["quantity"]
if type(sku) is not str or not re.fullmatch(r"[A-Z]{3}-[0-9]{2}", sku):
raise ValueError("sku must look like KIT-01")
if type(quantity) is not int or not 1 <= quantity <= 50:
raise ValueError("quantity must be an integer from 1 to 50")
result = {"snapshot": SNAPSHOT, "sku": sku, "requested": quantity}
item = stock.get(sku)
if item is None:
return {**result, "status": "not_found"}
return {**result, "status": "found", **item,
"enough_in_snapshot": item["on_hand"] >= quantity}
def checks(stock):
invalid = [
{**SAMPLE, "quantity": True}, {**SAMPLE, "quantity": "3"},
{**SAMPLE, "quantity": 0}, {**SAMPLE, "quantity": 51},
{**SAMPLE, "sku": "../private"}, {**SAMPLE, "sku": ["KIT-01"]},
{**SAMPLE, "tool": "reserve_stock"}, {**SAMPLE, "path": "other.csv"},
{"tool": "stock_lookup", "sku": "KIT-01"}, [],
]
for request in invalid:
try:
lookup(request, stock)
except ValueError:
pass
else:
raise AssertionError("invalid request accepted")
malformed = [STOCK + "KIT-01,Duplicate,1\n",
"sku,name,on_hand\nKIT-01,Kit,-1\n",
"sku,name,on_hand\nKIT-01,Kit\n"]
for text in malformed:
try:
load_stock(text)
except ValueError:
pass
else:
raise AssertionError("invalid stock accepted")
try:
json.loads('{"quantity":1,"quantity":2}', object_pairs_hook=unique_object)
except ValueError:
pass
else:
raise AssertionError("duplicate JSON key accepted")
assert load_stock("sku,name,on_hand\n") == {}
assert lookup(SAMPLE, {})["status"] == "not_found"
return len(invalid) + len(malformed) + 1
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--request", type=Path)
args = parser.parse_args()
request = SAMPLE
if args.request:
try:
with args.request.open("rb") as handle:
raw = handle.read(4097)
if len(raw) > 4096:
raise ValueError("request exceeds 4096 bytes")
request = json.loads(raw, object_pairs_hook=unique_object)
except (OSError, ValueError, UnicodeError) as error:
parser.error(str(error))
stock = load_stock(STOCK)
try:
result = lookup(request, stock)
except ValueError as error:
parser.error(str(error))
count = checks(stock)
print("OFFLINE TOOL TEST: no model or external service called")
print(json.dumps(result, sort_keys=True))
for sku in ("PAD-02", "ZZZ-99"):
row = lookup({**SAMPLE, "sku": sku}, stock)
print(json.dumps(row, sort_keys=True))
print(f"NEGATIVE CHECKS: {count} passed")
print("READ ONLY: no reservation, order or stock update")
if __name__ == "__main__":
main()
Expected complete output:
OFFLINE TOOL TEST: no model or external service called
{"enough_in_snapshot": true, "name": "Workshop cable kit", "on_hand": 7, "requested": 3, "sku": "KIT-01", "snapshot": "demo-stock-v1", "status": "found"}
{"enough_in_snapshot": false, "name": "Sketch pad", "on_hand": 0, "requested": 3, "sku": "PAD-02", "snapshot": "demo-stock-v1", "status": "found"}
{"requested": 3, "sku": "ZZZ-99", "snapshot": "demo-stock-v1", "status": "not_found"}
NEGATIVE CHECKS: 14 passed
READ ONLY: no reservation, order or stock update
The JSON keys are sorted so the output is easy to compare. JSON is a text format using double-quoted keys and strings. true and false are its Boolean values. The program prints results only; it saves no report and does not modify inventory.
3. Understand the three outcomes
The first result says that seven units in demo-stock-v1 cover a request for three. enough_in_snapshot deliberately avoids saying “reserved” or “available now.” Someone else may have bought units since a real export was taken. A reservation needs a separate authorized operation and concurrency handling, neither implemented here.
The second result is a known product with zero units. Its status remains found; the quantity comparison is false. The third result is not_found and has no invented count. An unfamiliar code might be mistyped, omitted from the export or outside the approved catalog. Tell the user the lookup did not find it and ask for clarification instead of asserting that stock is zero.
Notice the strict integer check. In Python, Boolean values behave like integers in some contexts. Testing type(quantity) is int rejects true rather than treating it as an order for one. The program also rejects strings such as "3" instead of silently guessing what the caller intended.
4. Submit your own request
Save this exact object as request.json in the same folder:
{"tool":"stock_lookup","sku":"KIT-01","quantity":8}
Run python lab.py --request request.json. The first result must now show requested as 8 and enough_in_snapshot as false, while on_hand remains 7. The two demonstration lookups after it still request three. Change the quantity to true and rerun: the command must exit with an error and print no stock result. Restoring the valid object restores the normal run.
The command-line file path is supplied by you, the operator. It is not an argument accepted by stock_lookup. The decoder rejects duplicate JSON keys so a request cannot contain two competing quantities. The input read is capped at 4,096 bytes; the loader caps the fictional catalog at 100 rows. These limits bound this exercise, not every resource an arbitrary Python process could consume.
5. Connect the idea to an assistant
For an optional manual trial, give a model the tool name, the three required fields, the permitted code format and the quantity range. Ask it to turn “check three of KIT-01” into one request object. Save its output as request.json, run the checker, and carry the returned JSON back to the conversation. Instruct the model to retain the snapshot limitation and never claim it reserved stock.
That is a manually mediated model-assisted lookup, not an automated API connection. We have not executed or evaluated that optional model step. A production adapter would map its provider's tool-call envelope into this request, validate it, execute the lookup and return the result. Anthropic's client-tool documentation describes tool names, descriptions and input schemas. This lab uses ordinary Python checks and implements no provider protocol.
To adapt it, replace only the fictional CSV with an approved export, revise the code format and quantity policy deliberately, and version each import. Keep personal records out. A real service also needs authentication, authorization, freshness checks, timeouts and restricted operating-system access. Removing write operations from one function is not a sandbox for arbitrary code. Product names are data, never instructions to execute.
Troubleshooting, limits and completion
An “expected columns” error means the export header differs from the contract. Map it explicitly; do not silently accept unrelated columns. A missing or negative count needs correction at the source. An unknown tool means the request tried an operation this program does not offer. JSON errors commonly come from single quotes, Markdown fences or duplicate keys. Fix the request rather than weakening the checks.
Completion check: reproduce the entire default output; demonstrate the eight-unit request returning false; reject a Boolean quantity; explain why an unknown code cannot justify a zero count. Then change the fictional count from 7 to 9 and confirm the eight-unit request becomes true. Restore the original data for the original output comparison.
The tests establish behavior for these supplied inputs, not security against every attack, current warehouse availability or a correct model decision. The useful fallback is a person checking the authoritative stock system. Next experiment: collect ten clearly labeled customer questions, including ambiguous product names and unanswered requests, then compare a model-assisted lookup with manual filtering. Count correct matches, clarification requests and review effort before adding an ordering tool.
Technical reference: Python 3.12 CSV documentation, particularly DictReader and its string-valued fields; Python JSON documentation, including object_pairs_hook. Sources checked October 16, 2026. This is original educational code with synthetic data, not a deployed inventory agent.