Skip to content

feat(server): a new pursue tool - #2926

Merged
Dani Akash (DaniAkash) merged 49 commits into
mainfrom
feat/pursue-tool
Oct 16, 2026
Merged

Dani Akash (DaniAkash) merged 49 commits into
mainfrom
feat/pursue-tool

Conversation

@DaniAkash

Copy link
Copy Markdown
Contributor

Testing a new pursue tool for BrowserOS neo, which pursues a whole outcome on a page rather than one step at a time.

Off unless it is turned on: the tool is not offered without a credential configured, and the settings field that configures it sits behind a feature flag, so a default install sees nothing new.

Not for general release yet. Opening this to try it on a few installs and measure what it is worth.

A decision model cannot generate text. It answers a probability, one option
from a set we author, or a position on a scale. Every shape in this crate
follows from that.

PageView turns a snapshot into the controls an operation could aim at, with a
text budget applied before the fingerprint so the hash covers what actually
travels. A control nested directly inside an identical control is collapsed:
Google News renders its section tabs that way and both reach the snapshot,
which split the probability between two options that do the same thing and held
the right answer at 0.61 where it needed 0.75. The test is nesting rather than
the name, so twenty sibling "Add to cart" buttons all survive.
The cap was 40, on the argument that confidence is measured against an even
split and collapses when the split is tiny. That is true of the share and
irrelevant to the decision, which turns on the distance between the top two
options. What 40 actually did was hide the answer: the same target question on
one measured page scored 0.42 with the right control cut from the list and 0.90
with the whole page offered.

So a question offers 254, which is the protocol's 255 less the escape option,
and what does not fit is offered in the next question rather than discarded. A
batch carries its own index because the state has to say which part of the page
it is about: a later batch asked with the first batch's state reads as a
question about a position the run is not at, which cost 0.33 of confidence when
it was measured, 0.57 against 0.90 for the same control.

Reserved slots become promotion. Reserving was the right answer under a cap,
where lifting a kind above the tiebreak pushed something else off the end.
Nothing falls off the end now, so moving a kind forward costs the thing behind
it a batch rather than its existence.
A share is not comparable between questions of different sizes. Spread over 254
options, 0.63 against a runner-up at 0.11 is seven times chance and a landslide;
an absolute bar of 0.75 refuses it, which it did three times in one measured run.

So each judgement carries a Lead: how far it led, what share it held, and
whether a distribution was reported at all. The bar that rises with consequence
is now the margin, 0.15 for a look, 0.30 for a change, 0.50 for a submit.

Two guards around it. A floor of 0.25 of share, because a leader at 0.20 over
0.02 leads by 0.18 and knows nothing; it binds only at the low bar, since a
margin cannot exceed its own share, and that is stated as a test rather than
left for someone to rediscover. And a judgement that reported no distribution
is judged on its share against the old absolute bars, because treating a
missing signal as a landslide would make it more permissive than the bar it
replaced: 0.70 on a submit was refused before and is refused now.

"purchase" joins the words that mean an expensive mistake. Matched whole, like
the rest, because substring matching once read "Noise Cancellation" as a cancel
and refused every listing on a search page.
A page larger than one question is asked about in batches, so one look at a page
can cost several questions. Three things keep that from being wasteful. A batch
holding three quarters of the answer stops the search, because nothing further
down the page is going to beat it: on one measured page, 7 of the 8 batches
asked were a re-search of a page the first had already answered. A page searched
to the end is not searched again while it stands still. And an answer needing no
control at all ends the search immediately, since no part of the page could
change what READ or DONE means.

What the run knows is keyed on the page fingerprint and dropped the moment that
changes, because every statement in it names controls by a reference the next
arrangement may have given to something else. An exhausted page is not an empty
one: the search stops and what it found stays.

One step now reads the page once. The loop read it at the top and again after
acting, with nothing touching it in between, and `is_fresh_for` was dead outside
its own tests. The three paths that change the page without acting on a decision
drop their view instead of carrying it forward, which is the one way this can go
quietly wrong.

The reply carries the page: url, title, the offered controls by reference, role,
name and state word, budgeted text, and how many controls are not in the list.
Not the whole tree, because sending that would put every intermediate page back
into the caller's context, which is the cost this design exists to avoid.

SNAPSHOT leaves the vocabulary. Every look is of a settled page now, so choosing
to look again costs a whole request to obtain what the next step does for free,
and an option that cannot be the right answer still competes for the answer.
EVALUATE is withheld for a different reason: it works, and no measured errand
reached for it.

All twelve of the earned behaviours still hold, unmodified.
One entry point for running a move rather than a method per tool, so adding a
tool is one match arm and one vocabulary option. A peer missing a tool gets a
working loop with that move left out, which is a vocabulary question rather than
an error.

Waiting is the part worth reading. browseros-core already knows how to do this:
it reads the tab's loading flag, waits out a navigation if it saw one, then
watches a MutationObserver counter until two reads agree. Nothing was calling it
from here. Its shared default budget is 50ms, which cannot reach its own 150ms
navigation detect window or its own 100ms quiet interval, and its comment says
that is on purpose so an agent's actions stay responsive, with the explicit wait
tool for anything slower. That trade is right for a caller that can name what it
is waiting for. This one cannot write a selector, and a wait decision costs it a
whole request, so it passes its own budget and the shared constant is left
alone.

So observe settles before it snapshots, and both flat sleeps go: sleeping before
a settle only delays the start of the wait. A WAIT the model chose gets a longer
budget than the one every observe already does, because otherwise it would be a
request spent on nothing.

The act tool is asked for diff="none". resolve_diff_detail(None) is Full, so
every acting step was rendering a whole page diff into a result this loop
immediately dropped.
Advertised only where the server has a credential for the decision model, so a
server without one does not offer a tool that cannot run.

The reply carries the page as structured content: url, title, the controls it
could choose between by reference, role, name and state word, budgeted text, and
how many controls are not in the list. That field is the point. A caller that
gets back `not_confident` and a sentence can only reword its goal and hope; one
holding control names can change the goal to something the page can satisfy.

Worth recording why it was not there before. The earlier version returned text
alone and explained that dispatch strips structured content from a tool with no
output schema. That is true of wire_result, which is on the browser-tool
dispatch path. A local tool returns through into_call_tool_result, which keeps
structured content unconditionally, so the constraint was never on this path.
The output schema is declared anyway, for the better reason: it puts the shape
in tools/list where a caller can see it, and makes the field contractual rather
than incidental. The run tool is the precedent on this server.

The tools/list test asserted a fixed catalogue, which the credential makes
environment-dependent. It now decides the expected list the way the server
does, so it passes with the credential and without it.
Found by running the thing. A button whose panel appears 400ms after the click
was retired as dead on every attempt.

The settle inside observe asks whether the page has stopped changing. That is
the wrong question for a delayed effect, because a page that has not begun
reacting is perfectly quiet: the act tool returned in under 10ms, the
MutationObserver counter was unmoved, one quiet interval passed, and the settle
reported the page settled. The loop then compared fingerprints, saw nothing, and
set aside a control that worked perfectly.

Neither the sampler this was ported from nor the branch it replaces had this
right. Both stop as soon as two consecutive looks agree, which a page that has
not started yet satisfies immediately.

"Has it started" is a question only the loop can ask, because the fingerprint
from before the act lives there. So the waiting lives there too and the driver
keeps its two methods: after a move, if the page looks unchanged, keep looking
for up to 1500ms before concluding the control is dead. Paid only on the
unchanged path, which is the path that was drawing the wrong conclusion. A page
that reacts at once costs nothing extra, and a control that genuinely does
nothing is still retired, after the wait rather than instead of it.
Found by reading the search box after a live run. Given the value
{"search term": "32GB DDR5"}, the box held the words "search term".

The model picks a value by name, because a name is something it can choose
between and a string is something it cannot write. The loop resolved that name
to the caller's text, checked the text existed, and then passed the decision
through with the name still in it, so the name is what reached the page. The
check was there and the substitution was not.

A select is deliberately left alone: its value came from the control's own
options, flattened into the question as one option per control and value, so it
is already the literal thing the page expects rather than a name standing in
for one. Both cases now have a test.

Without this the whole values path was decoration, which also means the
argument that a caller-supplied value beats a generated one had never actually
been exercised.
A run that typed into a search box had no way to search with it. The only
route was a key press, which aims at a control, and the control it had to
aim at was the field just filled: the repeat guard sets that aside as a
loop, and relevance had already demoted it. So the step that should submit
was asked to pick one of 254 controls, and answered that none of them
would do, which ends a run.

Submitting is its own way of acting now, and it aims at nothing. The field
is whatever the last step filled, which the loop knows and the model never
has to name. `needs_control` returning false is what carries that: no
target is required, none is judged, and the guard is never consulted.

The gate returns before the target head is read, beside the arms for
scrolling and for setting a dropdown. Reading that head is what sets the
"none of these" flag, and an answer about a question this move does not
depend on must not be able to end the run.

Measured on the other implementation of this loop, against the same bar
this one uses: the submit led by 0.38 where 0.30 was needed, on the errand
that ends with an item in the cart.
The target head is still asked on a step that turns out to be a submit,
because one request cannot know the answer to the way of acting before it
is built. So the model does name a control, and that name is discarded:
the head was not read for a submit and so was never judged, and acting on
an ungated judgement is what the gate exists to prevent. The other
implementation of this loop records the same thing, naming the Go button
for a submit it then performed with no reference at all.

The field is whatever the previous step filled, which the run already
recorded. With none recorded the reference stays unset and the driver
sends a bare key press to whatever holds the focus, which is what that
other implementation does in every case.

The test replays the live run. Its second turn answers "none of these" on
the target head on purpose, because that is what the model actually
answered on amazon.in, and with the submit arm removed the test fails with
the status that run returned.
`ActKind::Press` reads `key` and ignores `ref`: it sends the key to
whatever holds the focus. So a submit goes out as a press of Enter, and
the field the loop named is focused first, which is what makes the key
land in the box that was just filled rather than wherever the page left
the focus.

The other implementation of this loop presses Enter with no reference at
all and relies on the fill having left the focus in the field. That worked
on the page it was measured against. This reuses the focus-first path that
already exists here for a key press, and falls back to that bare press
when the run has no field to name.
Two different readers wanted two different things under one name. A
decision wants a small state, because irrelevant detail degrades the
answer as well as costing tokens, and that budget stays where it is. The
caller wants the page, because the caller is who the run was fetching it
for. Until now both got the decision's version, which is the first 1,500
bytes of the rendered tree, and on every page measured those bytes are a
skip-navigation block and the top of a menu.

Measured live: a run was sent to a docs page about a tunnel, walked eleven
steps, arrived at exactly the right url, and handed back 419 bytes of
chrome. The caller then read the same page itself on its next call, which
is the delegated work coming back undone.

So the reply now carries what the read tool returns, budgeted separately
and told apart from the state in as many words. Arriving is enough: the
text travels whatever status the run ends on, including the ones that end
without an answer.

The untrusted fencing comes off whole rather than being cut in half, since
a budgeted slice of a fenced block loses its closing marker, and a cut
says how much went so a caller can tell a whole page from a piece of one.
A step is something that ran. A decision is something that was paid for.
Only steps were reported, so a run that declined three times came back
having reported three decisions, no actions, and no trace of what it tried
three times. The gate had already worked out which judgement was weakest
and by how much it missed, and composed the sentence that says so, and the
loop matched on the verdict and threw the sentence away.

So every decision is now on the record, in order, with the verdict the
gate gave it, the head that was least certain in words, the margin it
actually led by, and which part of the page it was asked about. Endings
are recorded too, because the decision that stops a run is the one most
worth seeing.

`offered` and `unlabelledImages` were computed, carried as far as the
report, and then dropped on the way to the wire. They travel now.

This is a prerequisite rather than a feature. Three failures on amazon.in
and two on a docs site were all diagnosed by reading the source of the
thing that failed, and two candidate fixes were built and reverted because
there was no way to see which head had declined.
READ, GREP, COUNT and SCREENSHOT sat on the operation head together. For
any goal phrased "find out", "read back" or "explain", all four are
defensible, so they divided one probability mass four ways and none of
them led by the 0.15 a report needs. Measured on a live page: READ and
GREP tied at 0.16 while WAIT and BLOCKED took 0.22 and 0.21, a margin of
0.01, and the decision was declined. ACT on that same page won its own
head at 0.98, because which way to act is a second question.

So reporting is one option, and what to hand back is that second question:
the page's text, only the lines that mention what the goal asks about, or
a count of them. It is gated like any other judgement, so a report that
cannot say what it wants is not run, and the caller is told which head was
the weak one.

A picture is not among them. That path runs the screenshot tool and throws
its result away, so it hands back a claim that a picture was taken and no
picture. An option that cannot deliver still competes for the answer,
which is the rule that already keeps EVALUATE unoffered.

A peer that ships no grep is offered no way to match, and with nothing to
choose between the loop reads the page, which is the one way of reporting
that needs nothing chosen and cannot be wrong about what it was asked for.
The page travelling beside an answer was budgeted all along and the answer
itself was not, which is the wrong way round: the answer is the thing the
caller asked for. Measured live, a run chose to hand back the lines
matching its goal and returned 55,718 characters, which overflowed the
caller's result limit and was written to a file rather than read.

The read and grep tools fence their output as untrusted page content, and
that fence is a boundary around a block. Cutting the block in half loses
the closing marker, so the markers are kept and the content between them
is what gets cut, with a line saying how much went.
A docs page offered a button called "Search the API docs". The run was
asked for something that page did not list, answered that none of the
listed controls would do, and that ended the run. The same goal with the
button named in it took eleven actions and reached the right page, so the
affordance was present, usable, and unused. The gap was never capability.

So the escape answer now means what it says. Nothing listed can advance
the goal, and this page can search itself, therefore the thing the goal
needs is not on this page yet and searching is the move. The signal was
already computed and thrown away.

Once per page, and the flag sits beside the page fingerprint, so a search
that does not help is not tried twice and the page the search opens may be
searched in its turn. A page with no way to search itself hands back
exactly as before, which keeps this additive rather than a new way to
spend a budget.

Searching is its own kind of control now rather than a text field, so it
can be moved to the front of a question without dragging every form field
with it. Neither the role nor the name alone covers it: Amazon's is a
searchbox and this one was a collapsed button.

The promotion is triggered by the run's situation and not by the goal's
words, which is the point. A goal that says "search the docs" already
worked. The one to fix is the goal that does not say it.
…owhere

Both guards against looping were built on things that a real loop walks
straight past.

The repeat guard compared references. A page that re-renders mints a new
reference for the same control, so a run typed the same string into what
it believed were three different search boxes: e72, then e105, then e220,
all the same box, all "MCP tunnel". A name alone is no better, because a
results page carries sixty-five buttons called "Add to cart" and they are
sixty-five different controls. What tells the two apart is whether the
control aimed at last time is still on the page. Gone, with an identically
named one in its place, is a re-render. Still there is a different control.

The other guard fires when an action changes nothing, and every one of
those eleven actions changed the page. Opening and closing a panel is a
real change and no progress, and the loop had no way to tell the
difference, because everything it measured was change.

So progress is now its own measure: a step that leaves the url, the size
of the page and the completion score where they were has changed something
and arrived nowhere. Four of those in a row ends the run, and says which
of the two it was, since a page that keeps changing is exactly what made
this invisible. The limit is higher than the one for a dead control,
because a real errand opens a panel, reads it and closes it, and all three
of those are progress.
…ength

The old rule took the goal's longest word, which is a property of the goal
and says nothing about the page. Measured live: the goal named a Sensodyne
toothpaste, the page was toothpaste search results, the longest word was
"Toothpaste", and the match was nearly every line of it.

So the goal's words are scored against what is actually on the page and
the most selective one that still finds something wins. A word on no line
narrows nothing because it finds nothing, and a word on more than half the
lines narrows nothing either. Where no word narrows, there is no needle,
and the run reads the page instead of handing back a search that was never
going to help.
The sentence for a report asserted that nothing on the page had been
changed. That is a claim about the whole run, made by the sentence for its
last step, and it was only ever true when the run took no action.

Measured live: a run clicked an add-to-cart button, then chose to report,
and told the caller it had changed nothing in the same object that said it
had changed one thing. The caller read the sentence and concluded the
errand had failed, when the item was in the cart.

So a report now counts what the run did, the same way every other ending
does. The test that asserted the old wording is renamed and corrected: it
was pinning the bug.
A decision now carries a judgement for what to hand back as well as for
everything else, and it had outgrown the move that holds it: every other
variant carries almost nothing, so the enum was the size of its largest
one at every call site.
…bounded

Two corrections to the search change, both measured against live runs of
the errand it was built for.

The wording was harmful. Telling the model that a page's own search field
is a field made filling more attractive than submitting on a box that
already held text, and the two heads were already close. Three runs with
it: all three chose to fill again, two never considered submitting at all.
Three runs without it: all three chose to submit, and one carried the
errand through to the results page and the cart. The situational half of
the change is what was wanted; the sentence was undoing it.

The bound was weaker than its own commit message claimed. "Once per page"
was a flag beside the page fingerprint, and the flag is forgotten the
moment the page changes, which an active page does on every action. A live
run was refused three times and reached for a search all three. The
per-page flag still stops a second attempt on a page standing still, and a
run-wide limit now stops a moving page re-arming it.
A documentation site carries its entire navigation tree in its markup.
Measured on the guide a run had been sent to fetch: the whole-page read
spent all 4,000 bytes on lists of links to other pages, so the words the
goal asked about appeared nought times in the reply and the guide was
absent from its own result.

Asking for the main content first fixes it on the same page: the text now
opens with the guide's title and its first paragraph, mentions the tunnel
thirty-seven times, and answers what the goal asked. A page with no main
content falls back to the whole page, so nothing that worked stops working.

This is the selector the caller of that failed run reached for by hand on
its next call, which was the evidence that the work was being handed back
undone.
…content

The read tool reports an empty selector with a marker rather than with an
empty body, and it appends its own advice to the agent. So a page with no
main content came back as the marker plus two notes, which is neither
empty nor the page, and the fallback to reading the whole page never
fired: the caller got 329 bytes of advice instead of 4,000 bytes of page.

The advice does not belong in the page's text either way.
…ed at

The guard against going in circles asked whether anything had changed since
the last step, and on a lap of a loop everything changes every time.
Measured live on a docs site: four moves to a lap, three laps walked, the
url and the control count moving on every single step, the counter
resetting on every single step, and the page it was sent for already
reached on the fourth action. Twelve actions and 50,329 tokens for a page
it was standing on two thirds of the way back.

What catches a loop is novelty rather than change, so the run keeps the
set of page arrangements it has stood on and a step counts as progress only
when it lands on one that is not in the set.

The repeat guard keeps its existing rule, which stops a run acting twice
running on one control and sets that control aside. Widening it to the last
four aims and setting a control aside on any of them would forbid opening
a menu twice on purpose, so a repeat further back is told to the next
decision instead and the novelty of the page is what stops the run.

The fake browser that stood in for a toggling panel incremented its
fingerprint forever, so every arrangement looked new. A panel that opens
and closes comes back to where it started, and it now does.
A margin on its own says nothing. 0.23 is comfortable for a report and
refused for a submit, and a caller reading only the margin cannot tell
which it was looking at. So the bar the decision had to clear travels
beside it, taken from the same consequence the gate used.

A share travels too and is the least useful of the three: it is not
comparable between a question offering eleven options and one offering two
hundred and fifty four, which is why the gate reads the margin rather than
the share. Saying so in the field's own documentation is cheaper than
letting a caller compare two numbers that do not mean the same thing.
A caller asked whether the errand had worked and was told the page had
changed, which is not the same question. The loop cannot know an action
succeeded, and it should not claim to; what it can do is state what
observably happened and let the caller judge.

The generic way to get that, with no knowledge of any site: join the page
before an action with the page after it on the control reference, and keep
whatever kept its reference and changed its name. A cart counter going
from "0 items in cart" to "2 items in cart" is exactly that, and so is
"Showing 1-16 of 400", and so is a tab becoming selected.

Capped at three per step, with the ones the goal mentions first, because a
live page has a countdown on it and a run that moved everything should
report the cart rather than the clock.
The record of a run is a flat array of eleven fields a decision, and it
left the reading to the caller. The same information as prose is what a
caller deciding how to word its next goal actually reads:

    typed "sensodyne toothpaste" into Search Amazon.in
    submitted it, and the page became /s?k=sensodyne+toothpaste
    clicked Add to cart, and "0 items in cart" became "2 items in cart"
    none of the 254 controls it could choose between would do

A refused decision says which judgement was close and by how much, since
that is the thing a caller can act on. A hand-back says how many controls
it was choosing between, because "none of these" over eight controls and
over two hundred and fifty four are different statements.

A pure function of the outcome, so all of it is tested without a browser
or a model.
A caller that reads a refusal still has to work out what to do about it,
and the run already knows: it knows which judgement was weakest, whether
the page can search itself, whether it had just typed into a field, and
how much of the page it could see. Those are enough to say the one useful
thing.

    weakest was how to act on it, after a fill
      -> That field already holds your text. Ask me to submit it.
    weakest was which move to make
      -> Say what to do rather than what to find out.
    nothing offered, on a page with no search
      -> Name the control you mean, or give me a destination to go to.
    going in circles
      -> It kept returning to pages it had already seen. Give it one step
         at a time.

Appended wherever it applies: how many of the page's controls it was
actually choosing between, because "none of these" over 254 of 300 is a
different statement from "none of these" over all of them.

A run that finished gets no advice rather than a filled field, and the
sentence a person reads now ends with where the run left the browser.
A caller told to read a page that a dialog is covering reads the dialog.
Measured live: a run ended with its own search dialog open over the page
it had reached, and the next thing the caller did was read that page.

So a reply says whether the page it ended on is the thing on screen: a
dialog, an alert, or a dropdown left expanded. An ordinary page says
nothing, because a caveat on every reply is a caveat nobody reads.
Eight options on the primary choice where there were sixteen, and no two
of them mean nearly the same thing.

Reading, grepping, counting, checking one control's state and taking a
picture were all ways of observing a page, and every one of them is a tool
the caller already has and can aim better: it knows the goal, it can pick
a selector, and it is not working to a byte budget. Measured across two
sessions, the caller read the page itself after four runs out of five
anyway, once with a selector no budget here would have guessed.

So the vocabulary keeps the part only this loop can do, which is acting,
and the part only it can say, which is when the acting is finished. DONE
now means the page holds what the goal asked for and the caller should
read it, and whether the run changed anything on the way is already in
`actions` and `changed`.

Deleted with them: the question asking which of three things to hand back,
the needle chosen for a grep, the budget and the fencing that carried an
answer, and the read that filled the page's text in the reply. That
retires three findings unfixed because the code they were in is gone: a
screenshot that handed back nothing, a needle chosen by word length, and a
needle chosen by rarity.

One risk, stated rather than hidden: DONE is not gated, because an ending
touches nothing, and it is now the main way an informational goal
finishes. What makes that acceptable is that the completion score travels
beside it, the story says what the run actually did, and the caller reads
the page, which is the verification. The check moved to where the eyes
already were.
A caller wrote "open the docs page about the secure MCP tunnel and stop
once that page is showing", and the run searched, submitted, clicked
through, stopped on the right page, and reported nothing. It did what it
was told. The description had invited it: one paragraph promised that a
goal could be answered with the page's text handed back, which is no
longer how this works and was never how a caller should split an errand.

So the description says what a good goal looks like: describe the whole
outcome, including the part you want to find out, and read the page
yourself when it tells you which one. Splitting an errand into "get there"
and "read it" costs two calls and gets no report from the first.

And it says what to read in the result, in the order worth reading it:
the story of what the run did, then the one line saying what to change if
it did not finish, then the numbers behind each decision, where a margin
is to be compared with the bar beside it rather than read alone.
The model chooses a value by its name because it cannot write a string,
and the substitution happens on the decision handed to the driver. The
record was built from the decision before that, so a live run reported
that it had typed "search term" when it had typed "MCP tunnel".

Found by reading the story of a real run, which is what the story is for.
The credential was read in five places and compared against None in four
of them, and three more readers are about to arrive: the server
instructions, the wording of run's own description, and which body of the
managed skill is installed.

All four surfaces have to answer this question the same way and nothing
enforces that while each of them asks it separately. An agent told to
prefer a tool that is not listed, or offered a tool its instructions never
mention, is worse off than one told nothing at all.

So there is one type that answers it, read fresh wherever it is needed,
and one function that returns the secret itself for the single place that
builds a client with it. The two are separate because the value is a
secret and the answer to "should this exist" is not.
The two sentences in the browser guidance that say which tool to reach for
are now named constants, and a server holding a credential substitutes
pursue-first wording for them. A server without one substitutes nothing,
so what a production install reads is byte for byte what it read before.

The fallback rule is two unfinished replies rather than one. A single
refusal is usually a goal that needs a word changed, which the reply
already names, so falling back there throws away what the loop is for.
A catalogue entry that calls itself the primary way to drive the browser
outranks any amount of instruction text saying otherwise, because the
description travels with the tool and the instructions do not. So the
opening sentence is recast at the edge that knows whether the loop is
offered, rather than in the tool crate, which has no loop to rank.

Only that sentence changes. Everything after it is the SDK reference,
which the fallback needs in full.
The rule said "unfinished", which is not a status the reply can carry. The
statuses are satisfied and seven ways of not being satisfied, so the rule
now counts calls that came back with anything other than satisfied.
The skill loader needs the same answer the catalogue needs, and it is a
service: no service in this tree imports the API layer, and this change
is not the one to start. So the offering and the credential reader move
down beside the other settings a service may read, and the tool module
re-exports them so its callers are unaffected.
The skill's tool-choice paragraph is swapped at load time rather than the
guide being written twice: a second copy of a fifty line operating guide
drifts from the first within a release. Without a credential nothing is
substituted, so an install reads the guide it has always read.

The offering moves again, from a service to the configuration it is: the
boundary test rejects a new service-to-service edge, and this is an
environment read that two layers need rather than a service of its own.

The swap happens once, at launch, so a server that gains the credential
needs a restart before installed skills follow it.
The loop is abstracted over Driver, so the only thing missing outside the server is a browser. This supplies one over the agent-browser command line, which makes a change to the question payload measurable end to end without building the browser or standing up the MCP surface. It prints the ending, the decisions and question batches it took, the tokens and the wall clock, and every decision's margin against the bar it had to clear.
The run's memory said "ACT CLICK on e119, and the page changed". Both halves are useless to a model documented as holding nothing between calls: a ref is our own bookkeeping, and "the page changed" is true of every move ever made. The effects were already computed for the caller's story and thrown away before the next decision saw them.

Measured on an Amazon results page, three runs each, goal "add a Sensodyne toothpaste to the cart":

  before   0/3 satisfied, 10 to 12 decisions, 224k to 246k tokens, 18 to 23s
           and it added the item 3, 2 and 1 times, never once noticing
  after    2/3 satisfied, 2 decisions, 48k tokens, 3.7s, exactly one item

A loop that cannot tell a finished errand from an unstarted one does the errand again. Telling it that "0 items in cart" became "1 item in cart" is the whole fix.
A run can be diagnosed from its answers only as far as the answers were answerable, and nothing showed what a decision had actually been handed. With JEV_DUMP_DIR set, each request body is written to a file.

It immediately paid for itself. On an Amazon results page the state's page text was 1,495 bytes of a shortcuts menu, a banner and a category dropdown listing "Alexa Skills" and "Amazon Fresh Meat": no product, no price, not the word "result", and nothing matching the goal. The completion question was judging a goal about toothpaste against that.

Off unless the variable is set, and the body only. The credential travels in a header and is never written.
Controls that describe identically are collapsed to one option, because two the model cannot tell apart split the answer between them. Which one survived was whichever the ordering reached first, and on a commercial results page that is the advertisement at the top: asked to add a Sensodyne toothpaste, three runs out of three added a Colgate and reported the goal met.

So the survivor is now chosen by the row it sits in, scored against the words of the goal, in code and without a model call. Keyed on the whole description rather than the name, because a ticked and an unticked filter that share a label are two answers and not one.

Describing every duplicate by its row instead was tried first and measured worse: one page grew from 363 candidates to 499, cost twice the tokens, and the margins fell from 0.19 to 0.25 down to 0.01 to 0.19, because rows that differ only by a price are no easier to tell apart than names that do not differ at all.
A completion claim ended the run as satisfied with no gate on it at all. That is safe only while the page text travelling with the decision happens not to resemble the goal, which is not a property anything guarantees: given a results page whose products matched the goal's words, three runs out of three claimed the goal met in one decision, took no action, and returned satisfied.

The model is documented as answering the question as written and as losing reliability on multi-hop conditions, and "is this in the cart" asked of a page that merely names the thing is two hops. So a completion claim about a goal that asks for a change is accepted only once something has observably changed. It is handed back rather than refused, because the goal may genuinely have held before the run started and the caller can read the page.
The loop was one environment variable away from being a different product,
which meant nobody could turn it on without a shell and a restart. The settings
page already owned one server-side agent setting, so this extends that seam
rather than adding another.

A saved token takes precedence over JEV_TOKEN, and the read endpoint returns
where the credential came from rather than whether one exists. Three states,
because the environment case is real and a boolean cannot say it: without
naming it, the loop works, the page shows nothing, and the user pastes a token
they did not need.

The value goes in and never comes out. No endpoint returns it, the reply is
asserted to carry exactly two fields, and the file is written owner-only with
the mode set on the temporary file so it is never briefly readable at the final
path. It lives in a file of its own rather than as a key in the settings,
because the settings file is the kind of thing that ends up in a diagnostics
bundle, and a test asserts the token is not in it.

The write has its own route rather than an optional field on the switch's
request. A tri-state on a credential, where absent means unchanged and null
means remove, is how somebody's token eventually gets cleared by flipping an
unrelated switch. A test holds that line: saving the switch leaves a saved
token alone.

PursueOffering stops reading the environment and asks the settings store, which
owns the precedence, so no layer has to know there are two places to look and
the layers cannot disagree. The MCP service asks per call rather than caching,
so a token saved in the page shows up in the next tools/list with no restart.
Two things do not follow immediately and both were already documented in the
code: an installed skill is written at launch and needs a restart.

Two things the tests caught rather than me. The dependency boundary test refused
config.rs reaching into services, which was right, so TokenSource moved to the
configuration layer where it belongs. And the store's environment read is now
injectable, because asserting precedence against the real environment makes a
suite pass or fail by whose shell ran it, which this codebase has been bitten
by before.

The field is uncontrolled, so the credential sits in the input the user typed it
into and is read once on submit rather than mirrored into component state.
…wise

The field is now absent by default and appears only when PostHog reports
`ultrafast-token-field` for this install. The flag is created, active, and
targeted at specific installs by their analytics distinct id.

Flags had to be turned on first. The config disabled them with a comment saying
BrowserClaw does not use feature flags, and the option it used,
advanced_disable_feature_flags_on_first_load, suppresses the first evaluation
rather than hard-disabling the SDK, so the change is to stop suppressing it.
The refresh interval stays at zero: evaluated once when a tab initialises the
client and never polled, because a control that vanishes while somebody is
typing into it is a bug report rather than a rollout.

No server change. The app evaluates the flag itself through the client it
already bootstraps with the server's analytics id, so there was no status for
an endpoint to carry. The token routes, the store and the precedence are all
untouched.

The reader follows the capture-state reader already in the same module: a
replaced set of enabled flags, a subscription, and `useSyncExternalStore` in
the screen, as one screen already does for capture state. The set is replaced
rather than merged so a flag turned off upstream disappears instead of
lingering for the life of the tab, and an unknown flag reads the same as an
unevaluated one, so there is a single code path for not knowing yet.

Only the field is hidden. The routes stay reachable, so an install that saved a
token before the flag was turned off keeps offering pursue and can still remove
it from the API. A flag that could strand a credential with no way to undo what
it stored would be worse than the thing it was hiding, and a test says so.

Four tests on the gate, and one is the one that matters: the field is absent on
the first paint rather than a beat later, read before the render settles,
because that is the difference a person actually notices.

One thing worth knowing, recorded here because it cost time. `mock.module` is
process wide, so stubbing the analytics module in the screen's test broke the
module's own tests over which implementation was real. The screen's test now
spreads the real module, as its api client mock already did, and scripts the
flag through the same function PostHog's callback calls.

A consequence to accept knowingly: evaluation runs through the client the
telemetry opt-out disables, so an install with analytics off receives no
evaluation and does not see the field. That is stated in the flag's own
description in PostHog.
@DaniAkash Dani Akash (DaniAkash) changed the title feat(server): a new pursue tool, off unless it is turned on feat(server): a new pursue tool Oct 16, 2026

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; formal verification proved 0 of 26 changed function(s) (26 not verified).

Formal verification. PR-changed functions: 0/26 verified (0 proven, 0 may-equivalent, 0 distinguished) · 26 not verified (25 unsupported, 1 over cap).

Not verified on this run: router (unsupported), agent (unsupported), update\_agent (unsupported), mcp\_instructions (unsupported), prompt\_nudges\_saving\_repeatable\_tasks\_as\_skills (unsupported), prompt\_orders\_the\_reclaim\_flow\_and\_points\_it\_at\_the\_right\_place (unsupported), prompt\_uses\_tabs\_not\_windows\_for\_parallel\_work (unsupported), tests\_that\_instructions\_end\_with\_the\_page\_content\_guard (unsupported), tests\_that\_instructions\_with\_human\_help\_off\_name\_neither\_help\_tool (unsupported), tests\_that\_instructions\_with\_human\_help\_off\_say\_to\_ask\_in\_chat (unsupported), and 15 more; 1 changed function(s) beyond the cap and 0 skipped when the time budget ran out.


Graphify review — findings

Adds a browseros-decide crate and an MCP pursue tool that hands a goal to a decision model, offered only when a JEV token is configured. The server gates each action with a sure/hunch check and drives the page through a bounded, stable action space, and it falls back to the unchanged skill guide when the loop isn't installed. Adds Settings endpoints and hooks to save and remove the token, which is never echoed back and applies from the stored value or JEV_TOKEN; useSaveJevToken and save_jev_token handle the stored credential and its response. Turns on PostHog flag evaluation once at init, with no polling, and exposes isFlagEnabled and subscribeToFlags so the token field renders only after the flag arrives. Each evaluation replaces the flag set, and any unknown or never-evaluated flag reads as off.

Worth a look

  • Saved JEV token response discarded; settings cache never refreshed — packages/browseros-agent/apps/claw-app/modules/api/agent-settings.hooks.ts:46 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Token is validated after trimming but stored untrimmed — packages/browseros-agent/apps/claw-server/src/api/http/settings.rs:87 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 1059 functions depend on the 1043 functions this change touches.

Health — this change adds coupling hotspots:

  • new: read() — 15 callers, 5 callees
  • new: tell() — 18 callers, 3 callees
  • new: advance() — 1 callers, 20 callees
  • new: build() — 5 callers, 4 callees
  • new: search() — 1 callers, 7 callees
  • new: every_question_is_asked_in_one_request() — 0 callers, 6 callees
  • new: a_run_that_typed_can_submit_what_it_typed() — 0 callers, 6 callees
  • new: a_selects_own_value_is_passed_through_untouched() — 0 callers, 6 callees

Verification — 1059 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1059 function(s) in the blast radius were not formally verified this run

Risky patterns (advisory)

  • medium no-unbounded-fetch in packages/browseros-agent/apps/claw-server/src/services/agent_settings.rs:144: unbounded read (no limit/bound): if let Ok(saved) = self.jev_token.read()

Docs that may be stale (advisory)

…and 10 more.

Formal verification

Could not verify: Could not verify router.

The verifier did not have enough to check router, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Router<AppState>

Could not verify: Could not verify agent.

The verifier did not have enough to check agent, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: could not parse Rust function

Could not verify: Could not verify update\_agent.

The verifier did not have enough to check update\_agent, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: could not parse Rust function

Could not verify: Could not verify mcp\_instructions.

The verifier did not have enough to check mcp\_instructions, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: signature changed ((human_help: HumanHelp) -> String) → ((human_help: HumanHelp, pursue: PursueOffering) -> String) — a single call cannot feed both versions

Could not verify: Could not verify prompt\_nudges\_saving\_repeatable\_tasks\_as\_skills.

The verifier did not have enough to check prompt\_nudges\_saving\_repeatable\_tasks\_as\_skills, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify prompt\_orders\_the\_reclaim\_flow\_and\_points\_it\_at\_the\_right\_place.

The verifier did not have enough to check prompt\_orders\_the\_reclaim\_flow\_and\_points\_it\_at\_the\_right\_place, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify prompt\_uses\_tabs\_not\_windows\_for\_parallel\_work.

The verifier did not have enough to check prompt\_uses\_tabs\_not\_windows\_for\_parallel\_work, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify tests\_that\_instructions\_end\_with\_the\_page\_content\_guard.

The verifier did not have enough to check tests\_that\_instructions\_end\_with\_the\_page\_content\_guard, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify tests\_that\_instructions\_with\_human\_help\_off\_name\_neither\_help\_tool.

The verifier did not have enough to check tests\_that\_instructions\_with\_human\_help\_off\_name\_neither\_help\_tool, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify tests\_that\_instructions\_with\_human\_help\_off\_say\_to\_ask\_in\_chat.

The verifier did not have enough to check tests\_that\_instructions\_with\_human\_help\_off\_say\_to\_ask\_in\_chat, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify tests\_that\_instructions\_with\_human\_help\_on\_offer\_the\_help\_tools.

The verifier did not have enough to check tests\_that\_instructions\_with\_human\_help\_on\_offer\_the\_help\_tools, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify tests\_that\_the\_setting\_swaps\_only\_the\_help\_paragraph.

The verifier did not have enough to check tests\_that\_the\_setting\_swaps\_only\_the\_help\_paragraph, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unit-returning functions have no value to compare

Could not verify: Could not verify call\_tool.

The verifier did not have enough to check call\_tool, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify get\_info.

The verifier did not have enough to check get\_info, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify get\_tool.

The verifier did not have enough to check get\_tool, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify initialize\_info\_uses\_browseros\_neo\_identity\_and\_prompt.

The verifier did not have enough to check initialize\_info\_uses\_browseros\_neo\_identity\_and\_prompt, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify listed\_tools.

The verifier did not have enough to check listed\_tools, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported: method receiver/self type not synthesizable from the simple-type literal pools — abstaining rather than guessing a receiver

Could not verify: Could not verify tests\_that\_turning\_human\_help\_off\_hides\_both\_help\_tools.

The verifier did not have enough to check tests\_that\_turning\_human\_help\_off\_hides\_both\_help\_tools, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify tests\_that\_turning\_human\_help\_off\_swaps\_the\_instructions.

The verifier did not have enough to check tests\_that\_turning\_human\_help\_off\_swaps\_the\_instructions, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify tool\_surface\_exposes\_full\_catalog\_including\_run.

The verifier did not have enough to check tool\_surface\_exposes\_full\_catalog\_including\_run, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: anyhow::Result<()>

Could not verify: Could not verify new\_with\_home.

The verifier did not have enough to check new\_with\_home, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: AppResult<Self>

Could not verify: Could not verify load.

The verifier did not have enough to check load, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Self

Could not verify: Could not verify store\_in.

The verifier did not have enough to check store\_in, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: AgentSettingsStore

Could not verify: Could not verify harness\_skills\_missing\_unreadable\_or\_invalid\_runtime\_uses\_the\_fallback.

The verifier did not have enough to check harness\_skills\_missing\_unreadable\_or\_invalid\_runtime\_uses\_the\_fallback, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Result<(),Box<dynstd::error::Error>>

Could not verify: Could not verify harness\_skills\_runtime\_resource\_precedes\_the\_embedded\_fallback.

The verifier did not have enough to check harness\_skills\_runtime\_resource\_precedes\_the\_embedded\_fallback, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: unsupported return type: Result<(),Box<dynstd::error::Error>>

· 8 grounded finding(s) anchored inline below.

Comment thread packages/browseros-agent/crates/browseros-decide/src/gate.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/questions.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/questions.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/report.rs
Three commits landed on main that touched the same three files. Resolved as a
merge rather than a rewrite, so every commit on both sides stays as it was.

Two of the three were mechanical. The configuration conflict was both sides
adding to the same test module. The service conflict was main threading the
native capabilities through the tool catalogue while this branch threaded the
pursue offering; both now travel.

The third was not mechanical, and the test written for exactly this caught it.
The instructions used to substitute two sentences into one guidance paragraph.
Main split that paragraph in three to make room for the profile guidance, and
moved the second sentence into the shared paragraph. Substituting on the first
paragraph alone would have compiled, passed a careless review, and quietly left
the offered case telling agents to reach for run first. Each sentence is now
replaced in the paragraph it actually occupies, and the sentinel test counts
them where they now live.

590 server tests plus the integration suites, clippy clean under the workspace
gate, and the app tests unchanged.
Comment thread packages/browseros-agent/apps/claw-server/src/api/mcp/pursue.rs
Comment thread packages/browseros-agent/apps/claw-server/src/storage.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/vocabulary.rs
Comment thread packages/browseros-agent/apps/claw-server/src/api/mcp/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/view.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/report.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/pursue.rs
Comment thread packages/browseros-agent/crates/browseros-decide/src/client.rs
The merge commit carried the resolutions but not these five call sites, because
they were edited after the conflicted files were staged and a merge commit takes
the index. The result compiled and passed every test locally, on a worktree that
held the fixes, while the pushed tree did not have them.

mcp_instructions takes the offering and the native capabilities now, and
to_mcp_tool takes the capabilities, so three tests in prompt.rs and two in
service.rs needed updating. All five are test call sites; nothing about the
behaviour changes.
@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

✅ Tests passed: 3606/3609

Ran 15 of 16 suites (1 not affected by this change).

Suite Passed Failed Skipped
✅ server-agent 216/216 0 0
✅ server-api 289/289 0 0
✅ server-tools 256/256 0 0
✅ server-browser 10/10 0 0
✅ server-integration 10/10 0 0
✅ server-lib 199/199 0 0
✅ server-root 38/41 0 3
✅ agent 372/372 0 0
✅ claw-app 518/518 0 0
✅ claw-onboard 100/100 0 0
✅ app-onboard 84/84 0 0
✅ build 26/26 0 0
⏩ release n/a n/a not affected
✅ claw-server 1335/1335 0 0
✅ claw-server-quality passed 0 0
✅ claw-mcp 153/153 0 0

passed = ran successfully but emits no JUnit counts (a lint/format gate).

View workflow run

The quality job runs `cargo fmt --all -- --check` before clippy, and I had run
clippy without it. Formatting only; no behaviour changes.
@DaniAkash
Dani Akash (DaniAkash) merged commit 6fe3583 into main Oct 16, 2026
26 checks passed
@DaniAkash
Dani Akash (DaniAkash) deleted the feat/pursue-tool branch October 16, 2026 16:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant