Repository navigation
v0.3.0
Pre-releaseAdded
-
/docs/toolsanswers a machine as well as a person./docs/toolsand
/docs/tools/<name>now serve JSON to a client and HTML to a browser — the same content
negotiation/already used, so there is one URL per thing rather than two. The index lists
all eighteen tools (the four network ones included) with their modes and the contract's error
codes; a tool gives every mode's purpose, description, parameters with type, default and
required flag, and the example arguments. No key: discovery has to work before you have one.
tools/listover/mcpremains the authenticated, scope-aware route./advertises the new
endpoint astools. -
Every response says what it cost (#28 §6): a
metablock withtool,mode,
latency_ms(wall time in the server),compute_ms(time in the engine),version,
truncated— lifted out of the result so a caller reading a list knows it is not the whole
list — plusrequest_idandquotawhen the server has them.X-Request-Idand
X-Leftbrain-Latency-Mscarry the first two as headers; a caller-suppliedX-Request-Idis
kept so one id spans both sides of a trace.metanever affectsok, andcompute_msis
the regression alarm for the compute ceiling: a response whosecompute_msexceeds its own
deadline is a timeout that did not fire. -
A 15-second ceiling enforced by killing the work (#28 §1 step 3).
math,text,
validate,collectionsandnumberscalls now run in a worker process that can be
terminated, because a thread cannot be:int.__pow__,sreanddifflibare C loops that
never reach a bytecode boundary, so no async exception, signal or thread kill is ever
delivered — and the runaway holds the GIL, so the event loop and/healthzstarve with it.
That is how onemath.eval 9^9^9^9took the hosted instance offline for 35 minutes. A call
that reaches the deadline returnstimeoutwithstopped: "worker_terminated", the limit
and the real elapsed time; a caller-suppliedtimeoutis clamped to the ceiling; a burst
that cannot get a worker returns the retryablebusyrather than queueing behind a
15-second wait. Workers carryRLIMIT_CPUandRLIMIT_ASas a kernel backstop, use
forkserveron POSIX and are recycled every 200 calls to bound SymPy's caches. Configured
withLEFTBRAIN_COMPUTE_TIMEOUTand friends (README). Needspebble, added to theserver
extra; without it the server logs that isolation is off and runs in-process, and the library
always runs in-process. -
retryableon every failure, and four codes that say what was hit (#28 §1, §4): the
envelope's failure half is now{ok: false, error, message, details?, retryable, hint?}.
retryableisfalsefor everything exceptbusyandinternal— a client that reads only
ok: falseretries, and an identical retry of a call that hit a limit multiplies the load that
caused it. New codes alongsideinvalid_input/ambiguous/unsupported/timeout/
forbidden/internal:too_large(a pre-check refused it before any work started),
resource_exhausted(a memory or CPU limit rather than the clock) andbusy(saturated;
nothing was computed).contract.CODESis the one list, andTooLarge,ResourceExhausted
andBusyare raisable from a tool. Onlybusyis retryable by default:internalis the
catch-all for an exception no mode anticipated, and most of those are a deterministic
consequence of the input, so a site that knows its own failure was transient passes
retryable=Trueat the raise instead. -
Layer-0 size caps: the enormous is refused before it is computed (#28 §2e/§2f/§2g). Every
one of these reached the engine before;math.eval 9^9^9^9took the hosted instance down for
~35 minutes. Each now costs microseconds and comes back astoo_largenaming the limit and the
knob to turn:math: the expression is walked as an AST and the answer's size estimated in log10 space
before anything is evaluated — SymPy computesInteger ** Integerwhile parsing, so
9^9^9^9,2^(2^100),2^1000000,factorial(factorial(20)),gamma(10**10)and
exp(10^20)never start. The bound issys.get_int_max_str_digits()(4 300 by default),
which is the point beyond which CPython cannot render the integer at all.precisionis
capped at 5 000 digits andseriesorderat 50 terms.numbers:sequencecaps the largest term at 1 000 digits (geometricratio 2, n 10 000
ended at 2^10000 and returned 15 MB);allocatecapspartsat 10 000 (1 000 000 returned
116 MB).text:regex_replacerefuses an output over 200 000 characters;diffcaps each side at
10 000 lines, words or characters (difflib is quadratic — 100 000 lines never returned).collections:pivotrefuses more than 200 distinct pivot columns.datetime:business_dayscaps the range at 3 660 days;nowandconvert_tzcap the zone
list at 50.holidays:nextcapsnat 100.- A global 256 KB ceiling on any successful response backs all of them up, configurable with
LEFTBRAIN_MAX_RESPONSE_BYTES. A mode with a knob should refuse earlier and say which knob;
this only catches what slipped past one.
Fixed
-
Extreme magnitudes are rendered rather than lost or refused. Four behaviours for one
class of input:numbers.parse "1e400"wasinvalid_input(Decimalholds it exactly —
the parser's suffix regex simply never let it try); the same value as a JSON number
arrived asinfand came back as a successful"Infinity"with nothing said;
convert "1e-400"leakedOverflowError: int too large to convert to float; and
finance.compound principal=1e300wasinternalplus a bareInvalidOperation.
Now: scientific notation parses however large, a value that arrived as infinity says its
magnitude was already lost before the tool saw it, andconvertcomputes exactly at any
size — onlyvalue, which is a JSON number, saturates toInfinity/0, with the exact
answer in the newvalue_exactand the loss named inwarnings. An ordinary conversion
gains neither field. An amount too big to be money istoo_large, not a crash. -
The small list (#28 §3.13): sixteen findings that were each wrong or silent rather than
dangerous.random.samplewith more groups than items left some empty and said nothing;
random.float decimals=100ignored the parameter (a binary float has ~17 significant digits,
so it is now 0..15 and anything larger is refused);encode.base64 urlsafe=truedecoded
standard-alphabet text anyway;encode.urlleft a malformed%zzescape untouched with no
note;validate.urlaccepted embedded credentials and punycode/Cyrillic look-alike hosts
silently, and now warns about both;validate.iprejected the scoped IPv6 form
fe80::1%eth0(RFC 4007), which is now valid with thezonereported;
validate.sql_parsecounted a trailing comment as a third statement;
numbers.parserejected the Unicode minus−5, which is what copy-paste from a document
produces;collections.find_duplicateswithcase_insensitivedid not also trim, so
"a@x.com "and"A@x.com"were different;collections.aggregatehad nomedian
althoughmath.statsdid;datetime.recurrencerefusedevery other tuesday, the short
form people actually write;holidays.listfor a year the calendar has no data for looked
identical to a year with no holidays, and years past 2075 are now marked as estimated;
text.sortsortedéclairafterZebrawithout saying it does no locale collation; and
text.countnow reportsgraphemes— what a reader sees, so a ZWJ family emoji is one
character rather than five — and warns when text carries bidi controls or zero-width
characters, which are invisible, count towards length, and are how a filename is made to
read backwards. -
Unknown parameters are refused instead of silently dropped (#28 §2a). Every tool kept the
keys it recognised and discarded the rest, sodatetime.age dob=… ref_date=…returned the age
as of today (the parameter ison) andmath.plot_points start=… end=…plotted the default
range — an answer computed from defaults after the caller's arguments were thrown away, with
nothing in the response to tell it from a right one. Each of the fourteen core tools now
declaresMODE_PARAMS, what every one of its modes reads, and a call carrying anything else is
invalid_inputnaming the mode, listing what it accepts and suggesting the closest match.
tests/test_mode_params.pyderives the same map from the source and fails if the two drift.
Retired names (from/to) now say what replaced them instead of being ignored.overlapalso
honours a per-intervaltz, which it used to drop — two windows 12½ hours apart were compared
as wall clocks and reported as overlapping by 8 hours. -
Mutually exclusive parameters say which one won (#28 §2b):
numbers.round significant=2 decimals=5returned 120 with no word aboutdecimals, andfinance.emi months=12 years=5
gave a one-year loan to a caller who described a five-year one. Both now record the choice in
assumptions. -
Eleven confidently wrong answers (#28 §3.3–3.13). None of these failed; each returned
ok: trueand a value a caller would act on.encode.json parsereported a valid document
as invalid when it arrived through MCP already decoded (textis typedAny, so
{"a": 1}became Python's{'a': 1}repr);encode.json stringifyemitted literal
InfinityandNaN, which no strict JSON parser accepts, and now refuses;
datetime.parseturned01/02/50into 2050 with no note — there is now a stated pivot
(49 and below is 20xx) recorded inassumptions, because for a date of birth a silent
century is a hundred years wrong;datetime.recurrencewithFREQ=MINUTELYreturned four
copies of the same date, and a sub-daily frequency now keeps its times;
collections.pick_fieldssilently destroyed a field when two renames collided;
collections.find_duplicateswith a key no record has reported every row as a duplicate of
every other;datetime.free_slotsrefused two participants in the same timezone, which is
two colleagues in one city, and now numbers them apart;math.eval √2produced a symbol
calledsqrt2because the sign only bound inside brackets;collections.set_opsconflated
1andtrue(Python hashes them the same), and now compares by type as well as value;
finance.npv_irrreported one IRR for a cashflow with three sign changes and now reports
sign_changesand warns that Descartes' rule allows several. -
Ranges that were never checked, and approximations that should have been refusals
(#28 §2c/§2d). Every one of these returnedok: truewith a confident number:
geo_offlineacceptedlat=91(returningArctic/Longyearbyen, and a 10 118 km distance);
datetime.business_dayssilently swapped a reversed range and now reportssignand
directionlikediffdoes;convert.temperatureconverted −500 °C to −226.85 K, and a
reading below absolute zero is now refused while adeltadifference still may be any
sign;convert.currencyacceptedrate=-83;holidays.listacceptedmonth=13, and the
month filter now applies tolong_weekendsas well asholidays;datetime.convert_tz
answered a wall time the clocks skipped or repeated with no note, and now warns naming which
it is;geo_offline.distance "Delhi" → "Mumbai"returned 0 km because both were
approximated by Asia/Kolkata's reference city, and an unknown place is nowneeds: coordinates— a name whose reference city is the place (Kolkata,London) still
resolves;math.eval 1/0andtan(pi/2)returned SymPy's complex infinity rendered as
nan + naniand are nowinvalid_inputpointing atmode: limit;math.solveon a
degree-40 polynomial reportedno solutions foundwhere 40 roots exist, and now falls back
to numeric roots saying so inassumptions. -
Predictable inputs are phrased instead of raising Python exceptions (#28 §4): five modes
reported what broke inside leftbrain rather than what the caller did wrong.
collections.unflatten {"a": 1, "a.b": 2}wasTypeError: 'int' object does not support item assignmentand now names the key that is both a value and a prefix;scalewithto_qty: 0
wasZeroDivisionError: Fraction(1, 0);datetime.add amount=1e9 unit=yearswas
ValueError: year must be in 1..9999and is now a stated 4 000 000 limit;finance.compound years=1000000wasinternalplus a bareInvalidOperationand is nowtoo_largeat 10 000
years;convert.units value=1e400wasOverflowError: int too large to convert to floatand
is now a stated 1e300 magnitude limit. Each carriesdetailsand ahint. -
SSRF in
url_check(#28 §5):http://169.254.169.254/latest/meta-data/— the cloud
metadata service, which answers with credentials — was actually fetched, as werelocalhost
and RFC 1918 addresses. The host is now resolved and judged before the connection is opened and
again after every redirect (a public host can answer302to a private address), and only
publichttp/httpsaddresses are fetched.file:///etc/passwdwas being rewritten to
https://file:///etc/passwdand attempted; any non-http(s) scheme is now refused instead. The
connect budget drops from 15 s to 5 s. -
CSV formula injection in
collections.to_csv(#28 §5): cells such as
=cmd|' /C calc'!A0,+1-2and@SUM(1)were written verbatim, so opening the file in a
spreadsheet ran them. Cells beginning=,+,-,@, tab or CR are now prefixed with an
apostrophe, with the count inassumptionsandescaped_cells;escape_formulas: false
restores the old behaviour and warns. A negative number is left alone —-12.5is data. -
A regular expression can no longer freeze the process (#28 §1):
text.regex_matchwith
(a+)+$over"a"*40 + "b"never returned, and neither did(a|aa)+inregex_replaceor a
patterninside avalidate.json_schemaschema. Once stdlibrestarts backtracking nothing
can stop it —sreis a C loop that never reaches a bytecode boundary, so no timeout, signal
or thread kill is delivered until it finishes. The three shapes that cause it (a quantified
group that is itself unbounded, one that can match nothing, and a quantified alternation whose
branches overlap) are now recognised and refused withunsupportedbefore the pattern is
compiled, naming the shape and how to rewrite it.validate.regexjudges rather than runs, so
it keeps answeringvalid: trueand reports the newbacktracking_riskfield with a warning. -
Silent truncation now says so (#28 §2f):
text.regex_match'scountis the total number
of matches rather than the number returned — an agent readingcountoff a truncated response
got the limit and believed it was the answer.returnedandtruncatedare new fields.
datetime.recurrencewithcount=1000000returned 100 occurrences with nothing said about it,
because acountlarger thanlimitsilenced the warning; it now setstruncatedand warns.
holidays.nextwarns when the two-year search window holds fewer holidays than were asked for. -
A recursive JSON Schema is input, not a crash (#28 §1):
validate.json_schemawith
{"$ref": "#"}or 200-deepallOfraisedRecursionError, which surfaced asinternalwith
a traceback. Schemas nesting deeper than 50 levels are refused withtoo_large, and a$ref
cycle isinvalid_inputexplaining that validation cannot terminate. -
internalerrors no longer ship a stack trace to the caller (#28 §4): the traceback named
server file paths in every response. It is logged server-side now; setLEFTBRAIN_DEBUG=1to
get thetracefield back in the response as well.
Changed
-
A call that fails the input schema now answers in the contract (#28 §4):
convertwith no
arguments, orcollectionswithwhereas a string, used to come back as an MCP transport
error carrying a pydantic dump and anerrors.pydantic.devlink. Both now return
{"ok": false, "error": "invalid_input", …}with the offending parameters under
details.parametersandneeds.missingnaming anything required that was left out — the
rejected values themselves are the caller's data and are not echoed back. -
Per-key tool scopes (#27): a key can be limited to chosen tools and, per tool, chosen
modes — on the dashboard (a Tools disclosure on the create form, Edit scope on every
key's row) or withleftbrain-keys create|set … --tools "math,datetime,holidays:list+check"
(--all-toolslifts it). A scoped key'stools/listshows only the tools it may call, and a
tools/calloutside the scope returns the contract error
{"ok": false, "error": "forbidden", "message": "this key may not call holidays mode 'next'; allowed: list, check"}
as a result, not a transport error.GET /keys/meandleftbrain-keys listreporttools
(nullfor every tool). Existing keys have no scope and behave exactly as before; the
keystable gains a nullablescopecolumn on first start. -
math:round(...)over an expression withvarsfailed at parse time ("Cannot convert
expression to float") because it evaluated its argument before the variables were
substituted. It is now a deferred function that fires once the argument is numeric, and it
rounds half-up on the decimal value, returning an exact rational (round(2.675, 2)→
67/25= 2.68;round(basic * 12 / 365 * unpaid_days, 2)with vars →5128.77). -
datetime: a unix timestamp given as a digit string ("1787232546","1787232546000") was
read as a year and refused. Any 9–13 digit value, string or number, is now an epoch in every
mode that takes avalue(parse,convert_tz,add,diff, …), with the existing
"unix timestamp read as UTC" / "read as milliseconds" assumptions; the MCPvalueparameter
accepts numbers as well as strings, soconvert_tzcan take an epoch straight from another tool.