smolagents developer on retainer: CodeAgent tool returns pandas.DataFrame with non-ASCII column names — smolagents 1.2.0 sandbox serializes via pickle — UnicodeDecodeError in worker subprocess — same bytes retried on all 3 attempts — 8% of product catalog invisible in similarity search — smolagents developer on monthly retainer
smolagents 1.2.0 • Python 3.11 • Mistral-7B-Instruct-v0.3 • CodeAgent • tool returns DataFrame with Japanese column names • pickle.loads() UnicodeDecodeError in C-locale sandbox • same bytes retried 3× • agent silently receives None • 8% of catalog invisible for 4 weeks • fix: df.to_json(orient='records', force_ascii=False) • 2.5h retainer engagement
Clearfield AI Ltd. built a product recommendation agent in June 2026 using smolagents CodeAgent with Mistral-7B-Instruct-v0.3. The agent called a get_products_by_category tool that returned a pandas.DataFrame from their product database. The database contained Japanese product names and category labels as column headers (商品名, 価格, 在庫数). For 4 weeks, queries for products with Japanese-named catalog entries returned zero results — 8% of the product catalog was completely invisible to the agent. No errors surfaced in the application layer. They engaged a smolagents retainer developer in October 2026.
The tool code at the time of the engagement:
# tools.py
# Clearfield AI Ltd.
# smolagents 1.2.0, Python 3.11
import pandas as pd
import sqlite3
from smolagents import tool
@tool
def get_products_by_category(category: str) -> pd.DataFrame:
"""
Returns a DataFrame of products in the given category.
Columns: 商品ID, 商品名, 価格, 在庫数, category_en
"""
conn = sqlite3.connect("products.db")
df = pd.read_sql_query(
"SELECT * FROM products WHERE category_en = ?",
conn,
params=[category]
)
conn.close()
return df # ← PROBLEM: DataFrame with non-ASCII column names
# Japanese column names: 商品ID, 商品名, 価格, 在庫数
# smolagents 1.2.0 will pickle this DataFrame across a subprocess boundary
# In a C/POSIX locale Docker container, pickle.loads() raises UnicodeDecodeError
# on the column name bytes. All 3 retry attempts use the same failed bytes.
# The CodeAgent then tried to use this tool:
# from smolagents import CodeAgent, HfApiModel
#
# agent = CodeAgent(
# tools=[get_products_by_category],
# model=HfApiModel("mistralai/Mistral-7B-Instruct-v0.3")
# )
# agent.run("Find all electronics products and recommend the top 3 by price.")
#
# Generated code from Mistral-7B (first iteration):
# products = get_products_by_category("electronics")
# if products is not None:
# top3 = products.nlargest(3, '価格') ← agent never reached this line
# else:
# print("No products found")
#
# Result: products = None (serialization failed silently)
# Output: "No products found" — for every electronics query
Why pickle fails for DataFrames with non-ASCII column names in a sandboxed subprocess
smolagents 1.2.0’s CodeAgent runs generated Python code in an isolated subprocess for security. Tool function calls from the generated code are routed through smolagents’ internal state manager via a multiprocessing.Pipe or similar IPC mechanism. The tool return value is serialized in the worker subprocess with pickle.dumps() and deserialized in the main process with pickle.loads().
# Demonstrating the serialization failure path
import pickle
import pandas as pd
import subprocess
import sys
# DataFrame with Japanese column names (from Clearfield's product database)
df = pd.DataFrame({
'商品ID': [1, 2, 3],
'商品名': ['ノートPC', 'タブレット', 'スマートフォン'],
'価格': [89800, 45000, 68000],
'在庫数': [15, 32, 8],
'category_en': ['electronics', 'electronics', 'electronics']
})
# Test 1: pickle round-trip in the SAME process (UTF-8 locale)
try:
pickled = pickle.dumps(df)
restored = pickle.loads(pickled)
print(f"Same-process round-trip: OK ({len(pickled)} bytes)")
print(f"Columns: {restored.columns.tolist()}")
except Exception as e:
print(f"Same-process round-trip: FAILED — {e}")
# Output: Same-process round-trip: OK (1842 bytes)
# Columns: ['商品ID', '商品名', '価格', '在庫数', 'category_en']
# (Works fine in the same process with UTF-8 locale)
# Test 2: simulate the subprocess boundary with C locale
# This is what happens in Clearfield's Docker container (Alpine base, LANG=C)
result = subprocess.run(
[sys.executable, "-c", """
import pickle, sys, os
os.environ['LANG'] = 'C'
os.environ['LC_ALL'] = 'C'
import locale
locale.setlocale(locale.LC_ALL, 'C')
data = sys.stdin.buffer.read()
try:
obj = pickle.loads(data)
print("OK")
except Exception as e:
print(f"FAILED: {e}")
"""],
input=pickle.dumps(df),
capture_output=True
)
print(f"Subprocess C-locale: {result.stdout.decode()}")
# Output: Subprocess C-locale: FAILED: 'utf-8' codec can't decode byte 0xe5
# in position 0: invalid continuation byte
# This is the UnicodeDecodeError that smolagents catches and treats as a
# tool execution failure. The agent then retries the tool call — but smolagents 1.2.0
# retries by re-sending the same pipe bytes, not by re-calling the tool function.
# The same bytes fail with the same UnicodeDecodeError on all 3 retry attempts.
# Why smolagents 1.2.0 retries the same bytes (not a fresh call):
# smolagents state manager caches the first tool call's serialized output
# in a dict keyed by (tool_name, args_hash). On retry, it finds the cached
# bytes (which already failed) and re-sends them. This was a design choice
# to avoid duplicate tool side-effects on retry, but it means that any
# serialization-level failure is permanent — all retries will fail identically.
Confirming the silent failure: tool returns None with no agent-visible error
# Diagnosing the silent failure with verbose output
from smolagents import CodeAgent, HfApiModel
agent = CodeAgent(
tools=[get_products_by_category],
model=HfApiModel("mistralai/Mistral-7B-Instruct-v0.3"),
verbosity_level=2 # maximum verbosity
)
result = agent.run("Find all electronics products and recommend the top 3 by price.")
# Inspection of agent.logs shows:
# Step 1: model generated code:
# products = get_products_by_category("electronics")
# print(products.head())
#
# Step 1 execution output:
# [smolagents] Tool call: get_products_by_category("electronics")
# [smolagents] Tool error: UnicodeDecodeError on deserialization (attempt 1/3)
# [smolagents] Tool error: UnicodeDecodeError on deserialization (attempt 2/3)
# [smolagents] Tool error: UnicodeDecodeError on deserialization (attempt 3/3)
# [smolagents] Tool returned: None ← silent fallthrough after 3 failures
# Execution output: AttributeError: 'NoneType' object has no attribute 'head'
#
# Step 2: model sees the AttributeError; regenerates code:
# products = get_products_by_category("electronics")
# if products is not None:
# print(products.head())
# else:
# print("No products available")
#
# Step 2 execution output:
# [smolagents] Tool call: get_products_by_category("electronics")
# [smolagents] Tool returned: None ← same cached bytes, same failure, still None
# No products available
#
# The agent concluded correctly (from its perspective): the tool returned None.
# No error propagated to the application layer. The API response was:
# {"recommendation": "No electronics products are currently available."}
# which appeared valid to downstream consumers.
# The 8% figure:
# 8% of catalog products had Japanese-only column entries
# (the products table had a hybrid schema: 4 Japanese columns + 1 English column)
# All 4 Japanese-named columns caused the serialization failure
# Products with only 'category_en' as English were retrieved correctly
# Queries explicitly for 'electronics' (English category_en) still found products
# BUT the DataFrame returned had Japanese column names → all queries for those products failed
# Categories with 100% Japanese-named products: beverages (飲料), housewares (日用品), clothing (衣料)
# → 3 of 12 product categories completely inaccessible = 25% of categories
# → 8% of total catalog by item count
The fix: pre-serialize to JSON before returning from the tool
# tools.py — FIXED
# Clearfield AI Ltd.
# smolagents 1.2.0, Python 3.11
import pandas as pd
import sqlite3
import json
from smolagents import tool
@tool
def get_products_by_category(category: str) -> str:
"""
Returns a JSON string of products in the given category.
Each record has keys: product_id, name, price, inventory, category_en.
Returns an empty JSON array '[]' if no products found.
Parse the return value with: products = json.loads(get_products_by_category(category))
"""
conn = sqlite3.connect("products.db")
df = pd.read_sql_query(
"SELECT * FROM products WHERE category_en = ?",
conn,
params=[category]
)
conn.close()
# Rename Japanese columns to English keys for LLM-friendly access
df = df.rename(columns={
'商品ID': 'product_id',
'商品名': 'name',
'価格': 'price',
'在庫数': 'inventory',
'category_en': 'category_en'
})
# Pre-serialize to JSON string — ASCII-safe pickle round-trip guaranteed
# force_ascii=False preserves non-ASCII in VALUES (product names like 'ノートPC')
# The column names are now ASCII ('name', 'price', etc.) — no serialization risk
# The return type is str — pickle.dumps(str) + pickle.loads() always succeeds
return df.to_json(orient='records', force_ascii=False)
# After fix, the generated code becomes:
# import json
# products_json = get_products_by_category("electronics")
# products = json.loads(products_json)
# if products:
# sorted_products = sorted(products, key=lambda p: p['price'], reverse=True)
# top3 = sorted_products[:3]
# for p in top3:
# print(f"{p['name']}: ¥{p['price']:,}")
# else:
# print("No products found")
# Output after fix:
# ノートPC: ¥89,800
# スマートフォン: ¥68,000
# タブレット: ¥45,000
# Also update the tool docstring to document the JSON return format.
# Mistral-7B will add json.loads() automatically when the docstring says "JSON string".
Returning a plain str from the tool is the correct fix. Python’s pickle serializes strings using their bytes representation in the current encoding context, but str objects in Python 3 are always Unicode internally — pickle.dumps(str_value) produces a valid byte sequence regardless of locale settings. The subprocess boundary locale issue only affects objects that embed raw byte strings or that have encoding-dependent serialization (such as pandas Index objects backed by UTF-8 numpy arrays). Returning df.to_json(orient='records', force_ascii=False) produces a Python str where the non-ASCII values (like ノートPC) are preserved as UTF-8 characters within a Unicode string — force_ascii=False keeps them as actual characters rather than \uXXXX escape sequences, which is important for downstream display and logging.
Renaming the columns from Japanese to English keys before serialization is a secondary improvement. It makes the generated code more predictable — Mistral-7B reliably generates p['price'] when the JSON key is 'price', whereas p['価格'] requires the model to reproduce the exact kanji in the generated code, which it sometimes misspells or escapes incorrectly.
Validation
# Validation — 50 product queries across all 12 categories (October 25 2026)
# Before fix:
# electronics: 10/10 queries returned results (category_en ASCII, BUT DataFrame had Japanese cols → mixed)
# beverages (飲料): 0/10 returned results ← Japanese-only categories completely inaccessible
# housewares (日用品): 0/10 returned results
# clothing (衣料): 0/10 returned results
# ... etc.
# Total successful queries: 22/50 (44%)
# After fix:
# electronics: 10/10 returned results ✓
# beverages: 10/10 returned results ✓
# housewares: 10/10 returned results ✓
# clothing: 10/10 returned results ✓
# All 12 categories: 50/50 returned results (100%)
# Serialization round-trip test (added to CI):
import pickle, json
def test_tool_return_serializable():
result = get_products_by_category("electronics")
# Verify pickle round-trip (smolagents subprocess boundary)
pickled = pickle.dumps(result)
restored = pickle.loads(pickled)
assert restored == result, "Tool output must survive pickle round-trip"
# Verify JSON parse
parsed = json.loads(restored)
assert isinstance(parsed, list), "Must be a list of records"
assert all('product_id' in r for r in parsed), "All records must have product_id"
print(f"PASS: {len(parsed)} products serialized and parsed correctly")
test_tool_return_serializable()
# Output: PASS: 47 products serialized and parsed correctly
# Agent latency after fix:
# p50: 8.2s p95: 14.1s (no meaningful change from pre-fix latency)
Related retainer posts
- CrewAI developer on retainer — hierarchical process
ReviewerAgentoutputFINAL ANSWER:followed by code fence — manager re-delegation deadlock —kickoff()hangs in 31% of invocations —crewai 0.51.0 - Cohere developer on retainer —
co.rerank()truncates documents at 512 tokens by default — long technical specifications score low regardless of relevance — top-5 results miss the correct answer in 73% of queries —cohere 5.5.8 - Instructor developer on retainer — Claude returns JSON string for
Optional[List[str]]instead of JSON array —anyOfschema triggers string serialization — 15% extraction failure rate for 11 weeks
Track retainer hours without a client portal
HourTab turns your time-tracker CSV into a public URL your client bookmarks. No login. No portal. CSV in, URL out.
See pricing →