Skip to content

v0.3.0

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 27 Aug 12:45
· 101 commits to main since this release

Added

  • /docs/tools answers a machine as well as a person. /docs/tools and
    /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/list over /mcp remains the authenticated, scope-aware route. / advertises the new
    endpoint as tools.

  • Every response says what it cost (#28 §6): a meta block with tool, 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 — plus request_id and quota when the server has them. X-Request-Id and
    X-Leftbrain-Latency-Ms carry the first two as headers; a caller-supplied X-Request-Id is
    kept so one id spans both sides of a trace. meta never affects ok, and compute_ms is
    the regression alarm for the compute ceiling: a response whose compute_ms exceeds 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, collections and numbers calls now run in a worker process that can be
    terminated, because a thread cannot be: int.__pow__, sre and difflib are 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 /healthz starve with it.
    That is how one math.eval 9^9^9^9 took the hosted instance offline for 35 minutes. A call
    that reaches the deadline returns timeout with stopped: "worker_terminated", the limit
    and the real elapsed time; a caller-supplied timeout is clamped to the ceiling; a burst
    that cannot get a worker returns the retryable busy rather than queueing behind a
    15-second wait. Workers carry RLIMIT_CPU and RLIMIT_AS as a kernel backstop, use
    forkserver on POSIX and are recycled every 200 calls to bound SymPy's caches. Configured
    with LEFTBRAIN_COMPUTE_TIMEOUT and friends (README). Needs pebble, added to the server
    extra; without it the server logs that isolation is off and runs in-process, and the library
    always runs in-process.

  • retryable on 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?}.
    retryable is false for everything except busy and internal — a client that reads only
    ok: false retries, and an identical retry of a call that hit a limit multiplies the load that
    caused it. New codes alongside invalid_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) and busy (saturated;
    nothing was computed). contract.CODES is the one list, and TooLarge, ResourceExhausted
    and Busy are raisable from a tool. Only busy is retryable by default: internal is 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=True at 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^9 took the hosted instance down for
    ~35 minutes. Each now costs microseconds and comes back as too_large naming 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 computes Integer ** Integer while 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 is sys.get_int_max_str_digits() (4 300 by default),
      which is the point beyond which CPython cannot render the integer at all. precision is
      capped at 5 000 digits and series order at 50 terms.
    • numbers: sequence caps the largest term at 1 000 digits (geometric ratio 2, n 10 000
      ended at 2^10000 and returned 15 MB); allocate caps parts at 10 000 (1 000 000 returned
      116 MB).
    • text: regex_replace refuses an output over 200 000 characters; diff caps each side at
      10 000 lines, words or characters (difflib is quadratic — 100 000 lines never returned).
    • collections: pivot refuses more than 200 distinct pivot columns.
    • datetime: business_days caps the range at 3 660 days; now and convert_tz cap the zone
      list at 50.
    • holidays: next caps n at 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" was invalid_input (Decimal holds it exactly —
    the parser's suffix regex simply never let it try); the same value as a JSON number
    arrived as inf and came back as a successful "Infinity" with nothing said;
    convert "1e-400" leaked OverflowError: int too large to convert to float; and
    finance.compound principal=1e300 was internal plus a bare InvalidOperation.
    Now: scientific notation parses however large, a value that arrived as infinity says its
    magnitude was already lost before the tool saw it, and convert computes exactly at any
    size — only value, which is a JSON number, saturates to Infinity/0, with the exact
    answer in the new value_exact and the loss named in warnings. An ordinary conversion
    gains neither field. An amount too big to be money is too_large, not a crash.

  • The small list (#28 §3.13): sixteen findings that were each wrong or silent rather than
    dangerous. random.sample with more groups than items left some empty and said nothing;
    random.float decimals=100 ignored the parameter (a binary float has ~17 significant digits,
    so it is now 0..15 and anything larger is refused); encode.base64 urlsafe=true decoded
    standard-alphabet text anyway; encode.url left a malformed %zz escape untouched with no
    note; validate.url accepted embedded credentials and punycode/Cyrillic look-alike hosts
    silently, and now warns about both; validate.ip rejected the scoped IPv6 form
    fe80::1%eth0 (RFC 4007), which is now valid with the zone reported;
    validate.sql_parse counted a trailing comment as a third statement;
    numbers.parse rejected the Unicode minus −5, which is what copy-paste from a document
    produces; collections.find_duplicates with case_insensitive did not also trim, so
    "a@x.com " and "A@x.com" were different; collections.aggregate had no median
    although math.stats did; datetime.recurrence refused every other tuesday, the short
    form people actually write; holidays.list for 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.sort sorted éclair after Zebra without saying it does no locale collation; and
    text.count now reports graphemes — 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, so datetime.age dob=… ref_date=… returned the age
    as of today (the parameter is on) and math.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
    declares MODE_PARAMS, what every one of its modes reads, and a call carrying anything else is
    invalid_input naming the mode, listing what it accepts and suggesting the closest match.
    tests/test_mode_params.py derives 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. overlap also
    honours a per-interval tz, 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=5 returned 120 with no word about decimals, and finance.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: true and a value a caller would act on. encode.json parse reported a valid document
    as invalid when it arrived through MCP already decoded (text is typed Any, so
    {"a": 1} became Python's {'a': 1} repr); encode.json stringify emitted literal
    Infinity and NaN, which no strict JSON parser accepts, and now refuses;
    datetime.parse turned 01/02/50 into 2050 with no note — there is now a stated pivot
    (49 and below is 20xx) recorded in assumptions, because for a date of birth a silent
    century is a hundred years wrong; datetime.recurrence with FREQ=MINUTELY returned four
    copies of the same date, and a sub-daily frequency now keeps its times;
    collections.pick_fields silently destroyed a field when two renames collided;
    collections.find_duplicates with a key no record has reported every row as a duplicate of
    every other; datetime.free_slots refused two participants in the same timezone, which is
    two colleagues in one city, and now numbers them apart; math.eval √2 produced a symbol
    called sqrt2 because the sign only bound inside brackets; collections.set_ops conflated
    1 and true (Python hashes them the same), and now compares by type as well as value;
    finance.npv_irr reported one IRR for a cashflow with three sign changes and now reports
    sign_changes and 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 returned ok: true with a confident number:
    geo_offline accepted lat=91 (returning Arctic/Longyearbyen, and a 10 118 km distance);
    datetime.business_days silently swapped a reversed range and now reports sign and
    direction like diff does; convert.temperature converted −500 °C to −226.85 K, and a
    reading below absolute zero is now refused while a delta difference still may be any
    sign; convert.currency accepted rate=-83; holidays.list accepted month=13, and the
    month filter now applies to long_weekends as well as holidays; 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 now needs: coordinates — a name whose reference city is the place (Kolkata, London) still
    resolves; math.eval 1/0 and tan(pi/2) returned SymPy's complex infinity rendered as
    nan + nani and are now invalid_input pointing at mode: limit; math.solve on a
    degree-40 polynomial reported no solutions found where 40 roots exist, and now falls back
    to numeric roots saying so in assumptions.

  • 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} was TypeError: 'int' object does not support item assignment and now names the key that is both a value and a prefix; scale with to_qty: 0
    was ZeroDivisionError: Fraction(1, 0); datetime.add amount=1e9 unit=years was
    ValueError: year must be in 1..9999 and is now a stated 4 000 000 limit; finance.compound years=1000000 was internal plus a bare InvalidOperation and is now too_large at 10 000
    years; convert.units value=1e400 was OverflowError: int too large to convert to float and
    is now a stated 1e300 magnitude limit. Each carries details and a hint.

  • 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 were localhost
    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 answer 302 to a private address), and only
    public http/https addresses are fetched. file:///etc/passwd was being rewritten to
    https://file:///etc/passwd and 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-2 and @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 in assumptions and escaped_cells; escape_formulas: false
    restores the old behaviour and warns. A negative number is left alone — -12.5 is data.

  • A regular expression can no longer freeze the process (#28 §1): text.regex_match with
    (a+)+$ over "a"*40 + "b" never returned, and neither did (a|aa)+ in regex_replace or a
    pattern inside a validate.json_schema schema. Once stdlib re starts backtracking nothing
    can stop it — sre is 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 with unsupported before the pattern is
    compiled, naming the shape and how to rewrite it. validate.regex judges rather than runs, so
    it keeps answering valid: true and reports the new backtracking_risk field with a warning.

  • Silent truncation now says so (#28 §2f): text.regex_match's count is the total number
    of matches rather than the number returned — an agent reading count off a truncated response
    got the limit and believed it was the answer. returned and truncated are new fields.
    datetime.recurrence with count=1000000 returned 100 occurrences with nothing said about it,
    because a count larger than limit silenced the warning; it now sets truncated and warns.
    holidays.next warns 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_schema with
    {"$ref": "#"} or 200-deep allOf raised RecursionError, which surfaced as internal with
    a traceback. Schemas nesting deeper than 50 levels are refused with too_large, and a $ref
    cycle is invalid_input explaining that validation cannot terminate.

  • internal errors 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; set LEFTBRAIN_DEBUG=1 to
    get the trace field back in the response as well.

Changed

  • A call that fails the input schema now answers in the contract (#28 §4): convert with no
    arguments, or collections with where as a string, used to come back as an MCP transport
    error carrying a pydantic dump and an errors.pydantic.dev link. Both now return
    {"ok": false, "error": "invalid_input", …} with the offending parameters under
    details.parameters and needs.missing naming 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 with leftbrain-keys create|set … --tools "math,datetime,holidays:list+check"
    (--all-tools lifts it). A scoped key's tools/list shows only the tools it may call, and a
    tools/call outside 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/me and leftbrain-keys list report tools
    (null for every tool). Existing keys have no scope and behave exactly as before; the
    keys table gains a nullable scope column on first start.

  • math: round(...) over an expression with vars failed 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 a value (parse, convert_tz, add, diff, …), with the existing
    "unix timestamp read as UTC" / "read as milliseconds" assumptions; the MCP value parameter
    accepts numbers as well as strings, so convert_tz can take an epoch straight from another tool.