An agent REPL whose terminal went away kept running. Killing the tmux
session it ran in, or dropping the ssh connection, left read() returning
0 on stdin while select() kept reporting it readable, and isocline
treated that as "no key yet": the editor redrew and read again in a tight
loop, allocating each time. Two of them left behind by a screencast had
grown past 4 GiB each within minutes.
isocline now ends the edit on EOF or a read error, as Ctrl-D on an empty
line does, so the REPL quits. Its signal handler also stops swallowing
termination signals it has no previous handler to chain to, which is
what let the SIGHUP from that hangup go unnoticed.
SIGTERM in the REPL still behaves like Ctrl-C: that is Sighandler's
no_hard_exit, and isocline never sees it.
zenai adds `Effort.null` (send no reasoning field, model default) and now
sends `.none` explicitly so reasoning can be turned off on models that
reason by default. Mistral defaults to `.null`, and its array content
from reasoning models parses.
Keep `.null` and `.none` as chosen in the /save synthesis turn instead
of bumping them.
zenai now asks for identity encoding, so a compressed reply no longer
stops std.http from pooling the connection. Calls behind a compressing
CDN, TypeSafe's included, stop paying a new TLS handshake each time.
Picks up lightpanda-io/zenai#37: on a network that advertises an IPv6
default route without a global address, new LLM connections no longer
stall until the IPv6 SYN times out.
Measuring the agent took a logging proxy in front of the provider: the
usage it prints is a run total, and nothing records per-tool latency,
result size or the page each call left behind. --trace PATH writes, as
each step ends, a JSON line per model call (phase, latency, tokens,
finish reason, tool calls requested) and per tool call (arguments with
$LP_* placeholders kept, latency, result size, error, navigated, page),
then a summary.
Model records come from zenai's before_request/after_response hooks.
Tool records come from Session.tool_observer, read by browser_tools.call
and by the script runtime when an async goto settles, so every path is
covered without opting in.
The $usage line now uses the trace's token fields: input (all input),
cached, cache_creation and output, replacing prompt (fresh input only),
completion and total, so the two can't be read with different meanings.
Conversation.prune only runs between turns, and a --task run is one
turn, so every tool result piled up and was re-sent on every request:
on a browsing benchmark 135k of 138k chars of tool results in the last
request were from pages the model had moved past, and calls averaged
27-32k input tokens (up to 52k).
compactForRequest runs before each model request (zenai's new
before_request hook). It keeps the newest 3 large results, extract
output and anything under 2 KiB, and stubs older ones only once they
add up to 60 KiB, so history changes in batches and the provider's
prompt cache survives between them. Image expiry moves to the same
hook and shares the walk.
lightpanda-io/zenai#31: --provider anthropic --base-url now accepts the
Anthropic SDK form (no /v1) that Anthropic-compatible gateways document,
instead of returning HTTP 404.
lightpanda-io/zenai#30: --provider openrouter (anthropic/* models) and
--provider vercel now send the gateway's cache hint, and cache writes
show up in the $usage line.
Dependencies:
- v8: zig-v8-fork 0.17 branch (lightpanda-io/zig-v8-fork#218).
- sqlite3: build the amalgamation directly; the allyourcodebase wrapper
has no 0.17 support yet.
- pcre2: master commit with 0.17 support (no release yet).
- translate_c package (2.0.0) replaces the deprecated addTranslateC.
default_init is set to keep 0.16's zero-initialized struct fields.
Build:
- b.pathFromRoot/build_root/args/sysroot are gone: resolve paths from
b.root, use addPassthruArgs, declare configure-time file and directory
dependencies, and poison the configure cache for the git version.
- Drop curl config values the template never used (now an error).
- Drop the 0.16 zip-fetch workaround from the Makefile and CI.
- -Doptimize=ReleaseFast -> -Doptimize=fast.
Language and std:
- @intFromEnum/@enumFromInt -> @backingInt/@fromBackingInt.
- Struct-of-arrays @typeInfo (field_names, field_types, decl_names,
param_types, error_names).
- `a ** n` removed: @splat for arrays, string.repeat for strings.
- errdefer captures removed: split WebDriver.run and fulfillRedirect.
- @hasDecl only sees pub decls: make StyleManager's Spec.finish pub,
otherwise it silently stops running.
- SafeAllocator replaces DebugAllocator; the test runner now has to
initialize std.testing.allocator_instance itself.
- std.fmt.allocPrint/bufPrint -> Allocator.print/std.mem.print,
dupeZ -> dupeSentinel, std.builtin -> std.lang, builtin.os ->
builtin.target.os, zon.parse arena API, BufferFirstAllocator, and the
remaining renames (getLastOrNull, bit_set, ascii.find*, meta.Int).
Picks up effort=none sending thinking disabled to Anthropic (zenai#19)
and the reasoning max_tokens floor on every provider (zenai#18).
ErrorDetail now carries the parsed error message instead of the raw
body.
A BCP 47 tag in LC_ALL is not a POSIX locale, so setlocale(LC_ALL, "")
failed for the rest of the process and for any child. In the REPL,
isocline took the terminal for non-UTF-8 and dropped every non-ASCII
keystroke. Set ICU's default locale through the new
v8__V8__SetDefaultLocale binding and leave the C library alone.
Pins zig-v8-fork to lightpanda-io/zig-v8-fork#209; CI links once that is
tagged and action.yml's zig-v8 is bumped.
isocline copied the submitted line into a buffer one byte short whenever
the locale is not UTF-8. A line whose length the allocator serves
exactly, such as the 24-byte `/screenshot path=out.png`, corrupted the
heap on Enter.
Picks up the per-effort `max_tokens` floor, so a tight budget no longer
buys mostly thinking and a truncated answer, and the search clients
logging a non-2xx like the model clients already did.
The bump also carries zenai's `ErrorDetail.body` -> `.message` rename,
which the search failure path reads.
Requires: https://github.com/lightpanda-io/zig-v8-fork/pull/207
Inspired by https://github.com/lightpanda-io/browser/pull/3504 this simplifies
v8::Value serialization (e.g. as used in console.log(...)).
1 - It doesn't executes JS and thus can't have a side effect, which is otherwise
possible if we invoke a getter or through a proxy
2 - It removes the debugValue debug-only path
(2) is potentially a loss in debug builds, but I think the usefulness of that
was always, at best. The upside is code elimination and consistency in how
values are reported in debug/release
The v8 documentation says this "Has to be called", and claude flagged it as a
potential leak.
Required a new zig-v8-fork build (v0.5.5) which I released.
Filter lists carry a few hundred rules written as JavaScript regex
literals (24 in EasyList, 165 in uBO's badware list); they parsed but
were dropped as unsupported. PCRE2 reads that syntax as-is, `\/` and
friends included, its compiled patterns are immutable so the one
blocker shared by every HTTP client thread can run them, and 10.48
ships a build.zig for 0.16, so it is wired like sqlite3.
`Regex.Context` routes every PCRE2 allocation through the blocker's
allocator, which puts the compiled patterns under the test runner's
leak detection, and caps match and depth so a broken pattern costs a
false negative rather than a stalled request. As in uBO, a regex
tests the raw URL with the case-insensitive flag unless `$match-case`.
Regex filters are still never tokenized: they ride the fallback bucket.
curl 8.22 moved the resolver sources into lib/vdns/, renamed the QUIC
backends to cf-ngtcp2*/cf-quiche, folded noproxy.c into proxy.c and
added a handful of new files. Regenerate the source list from the
tarball and drop the four config defines the 8.22 template no longer
declares (Zig errors on unused config header values).
zenai tool results now carry image parts (lightpanda-io/zenai#12, #13),
so the model-driven tool path opts into inline images: a screenshot
without `path` reaches the model as text plus the PNG, on every backend.
The slash-command path still needs `path`, its result goes to the
terminal.
https://github.com/lightpanda-io/browser/pull/3271 introduce an orderfile so
that hot sections of code were grouped together in the binary, resulting in more
efficient loading. But, it excluded v8 functions because v8 is compiled with
`-fno-unique-section-names` so each section gets the same name. Because of this
the orderfile can't target specific "hot" or "cold" functions.
As a follow up to 3271, I thought we'd be able to re-compile v8 without that
flag, but:
1 - The flag isn't directly exposed by v8, so we'd need to change v8's own build
BUILD.gn in our build process
2 - It would make the .a file ~ 40MB larger (though the final lightpanda binary
would stay the same size
3 - If we wanted to created two .a files (one with -fno-unique-section-names and
one without), it would require a full rebuild (for both x86-64 and arm).
I didn't love those compromises, so I asked Claude if the sections names could
be generated in a separate file and then that could be used when generating out
lightpanda.ld. What claude came up with was a small Zig script
(mark_hot_sections.zig) which is now run as part of the build. It takes the
v8.a file (which still has `-fno-unique-section-names`), it takes a text list
of hot functions, and it re-generates v8.a with a special .text.hot and
.rodata.hot sections. lightpanda.ld can include these.
This PR depends on https://github.com/lightpanda-io/zig-v8-fork/pull/202 but
202 doesn't require a v8 rebuild. It merely allows prebuilt_v8_path to be
a lazypath, which we need because the prebuilt_v8_path that we pass is now
generated from this build script.
The DuckDuckGo HTML endpoint the search tool fell back to is disallowed by
its robots.txt, so it is removed: the `duckduckgo` engine, the scrape via
`performGoto`, and the tool's browser-side dependency (`execSearch` no
longer needs a session). In its place `.auto` is a single walk over the
API engines in table order — each when its key is set — with Keenable
last, keyed if `KEENABLE_API_KEY` is set and its public endpoint
otherwise. Search therefore always has a rung with zero configuration,
and a set key is never shadowed by a keyless retry.
Every search now goes through zenai's `std.http.Client`, which had no
timeout at all; a stalled response would have blocked the MCP server's
shared browser thread for every session. zenai now bounds each attempt
(lightpanda-io/zenai#10, pinned here), and the tool's `timeout` argument
drives it, defaulting to 10 s. Retries are a property of the call path:
off in the cascade, where the next rung is the retry, and the engine
default for an explicit engine.
Cleanups from the #3252 review: `keyless` is derived from the client's
`api_key` type instead of a hand-set table flag; `engineIndex` no longer
depends on enum declaration order; one `searchKeyStatus` query replaces
three pub helpers for the REPL; the engine order and env-var list in the
tool description and `/searchEngine` help are generated from the table;
an empty result title renders as its URL instead of `****`.
- build.zig.zon points at lightpanda-io/zenai's merge commit of zenai#9
(same tree, so the hash is unchanged)
- the keyless rung is no longer skipped when KEENABLE_API_KEY is set:
the public endpoint has its own rate-limit regime, so it is worth
trying after a failed keyed attempt — which the search tool
description already promised
- keenable retries are disabled: the fallback cascade is the retry
mechanism, and honoring the public endpoint's 60s Retry-After would
stall the search tool short of the DDG rung
- stale SearchEngine/searchExplicit doc comments updated for keyless
zenai's review round made keyless explicit in the client's type
(init takes ?[]const u8; null selects the public endpoint) and removed
the always-empty description field from results:
- the .auto keyless rung and searchExplicit pass null instead of "";
the null-key call is comptime-gated to keyless-capable engines and
apiSearch's key parameter is anytype to admit both shapes
- a set-but-empty KEENABLE_API_KEY is now keyed and fails loudly
instead of silently switching to the keyless rate-limit regime
- formatKeenableMarkdown reads snippet only (description no longer
exists to fall back to)
- build.zig.zon repinned to the reviewed zenai commit
Adds keenable to the search tool's engines (KEENABLE_API_KEY, tried
after brave/tavily/exa in .auto). Unlike the other engines it also
answers without any key: its client routes an empty key to the public
endpoint (rate-limited per client IP), so .auto now tries that as the
last rung before the DuckDuckGo scrape, and an explicit
/searchEngine keenable works keyless instead of erroring. Engine table
entries gain init_options so the Keenable client can carry the
lightpanda attribution header.
Needs the zenai Keenable search client (lightpanda-io/zenai PR #9); the
zon pin points at that branch until it lands.
Supports prebuild-v8 shared library and adds a `download-v8-shared` make target.
Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/198
The auto-discovery of the .so is done in the build.zig, which is different than
the auto-discovery of the .a which is done in the Makefile. This is intentional
and if we inverse -Ddev_fast so that it's on by default, the .a's auto discovery
will be moved to build.zig for consistency and explicitness. As-is, we have no
way to tell whether -Dprebuilt_v8_path=v8.a is being injected by Makefile or
is explicit (which isn't a problem so long as we're not doing dev_fast by
default).