localagentbrowser 1.41__tar.gz → 1.47__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {localagentbrowser-1.41 → localagentbrowser-1.47}/CHANGELOG.md +90 -0
- {localagentbrowser-1.41/localagentbrowser.egg-info → localagentbrowser-1.47}/PKG-INFO +48 -15
- {localagentbrowser-1.41 → localagentbrowser-1.47}/README.md +47 -14
- {localagentbrowser-1.41 → localagentbrowser-1.47}/agent_browser.py +120 -19
- {localagentbrowser-1.41 → localagentbrowser-1.47/localagentbrowser.egg-info}/PKG-INFO +48 -15
- {localagentbrowser-1.41 → localagentbrowser-1.47}/pyproject.toml +1 -1
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_decisions.py +126 -1
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_journal.py +5 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_model_turns.py +12 -4
- {localagentbrowser-1.41 → localagentbrowser-1.47}/LICENSE +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/MANIFEST.in +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/SOURCES.txt +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/dependency_links.txt +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/entry_points.txt +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/requires.txt +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/top_level.txt +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/setup.cfg +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/conftest.py +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/fakes.py +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_duplicates.py +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_main_loop.py +0 -0
- {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_page_text.py +0 -0
|
@@ -4,6 +4,96 @@ Every version of `agent_browser.py`, newest first. Each version is also a tagged
|
|
|
4
4
|
Timings are from live runs on the development machine (Intel i5-9500, 6 cores, no GPU, Qwen2.5-7B-Instruct
|
|
5
5
|
Q4_K_M in LM Studio), where reading the prompt takes about 90% of every model call.
|
|
6
6
|
|
|
7
|
+
## [1.47] - 2026-10-16
|
|
8
|
+
A list task on 1.46 (three free, open-source alternatives to Microsoft Office and what each is best at)
|
|
9
|
+
finished on its own in 18 turns and 49 minutes of model time. LibreOffice and OnlyOffice were right, but the
|
|
10
|
+
third item was FreeOffice, which is free but not open source, although the agent had also recorded Apache
|
|
11
|
+
OpenOffice. It recorded LibreOffice four times in different words, which the wording-based duplicate check
|
|
12
|
+
does not catch. Both are left for future work.
|
|
13
|
+
### Fixed
|
|
14
|
+
- The report's number check flagged the citation numbers in "[4, 5]" as unverified numbers; grouped
|
|
15
|
+
citations like that are now recognised along with "[4]".
|
|
16
|
+
|
|
17
|
+
## [1.46] - 2026-10-16
|
|
18
|
+
### Fixed
|
|
19
|
+
- The rerun of the factual question on 1.45 had another accurate discovery sent back ("Mount Kilimanjaro is
|
|
20
|
+
the tallest mountain in Africa and its elevation is 5,895 meters", where the page says "5,895 m"); the
|
|
21
|
+
rewrite dropped the height in feet. Short discoveries have only about 15 word pairs, so a few connecting
|
|
22
|
+
words decide their score. The page-match check now skips discoveries under 20 word pairs; the invented
|
|
23
|
+
steps and care routines it is meant for had 27 to 75.
|
|
24
|
+
|
|
25
|
+
Rerun: FINISH on turn 4 with the correct, cited answer in 12.0 minutes of model time (11.8 on 1.40).
|
|
26
|
+
|
|
27
|
+
## [1.45] - 2026-10-16
|
|
28
|
+
### Fixed
|
|
29
|
+
- The page-match check sent back an accurate discovery on the factual question, "Mount Kilimanjaro is the
|
|
30
|
+
tallest mountain in Africa and its height is 5,895 m (19,341 ft)", which echoed the question's "tallest"
|
|
31
|
+
where the page says "highest" (65% of word pairs matched). The model then recorded the parts separately,
|
|
32
|
+
and the run took 6 turns and 17.5 minutes of model time instead of 4 and 11.8 on 1.40. The objective's
|
|
33
|
+
own wording now counts as matched: that discovery scores 76%, while the invented repotting steps and care
|
|
34
|
+
routines still score 28-57%.
|
|
35
|
+
|
|
36
|
+
## [1.44] - 2026-10-16
|
|
37
|
+
On a how-to (repotting a houseplant: what you need, the steps and care for the first two weeks), 1.43 was
|
|
38
|
+
shown part 2 of a guide that ended "Just follow these 6 easy steps", and recorded six steps it wrote itself;
|
|
39
|
+
the real ones were in part 3. It then recorded a care routine the page never gave.
|
|
40
|
+
### Added
|
|
41
|
+
- Page-match check: a DISCOVER is compared with the page text shown so far (consecutive word pairs). Real
|
|
42
|
+
discoveries from earlier runs shared 64-100% of their word pairs with their page; the invented steps
|
|
43
|
+
shared 11-44%, and a rerun's mix of real pot-size advice and invented steps 57%. Below 70%, the DISCOVER
|
|
44
|
+
gets one cheap retry asking for only what the page says in the parts shown, in its own wording. Repeating
|
|
45
|
+
the same DISCOVER goes through (a heavy paraphrase can score low); a different one is checked again.
|
|
46
|
+
### Changed
|
|
47
|
+
- The "page continues" line under a part says that what comes next has not been shown yet.
|
|
48
|
+
- The read-on check remembers parts recorded on an earlier visit to the same page.
|
|
49
|
+
|
|
50
|
+
Rerun on the same objective: at the point where 1.43 invented the steps, the agent recorded only what the
|
|
51
|
+
page said and read on; its discoveries hold the guide's real materials list, steps and aftercare, copied
|
|
52
|
+
from the page. It finished on its own on turn 24 (67.5 minutes of model time). The final result still has
|
|
53
|
+
flaws: it leaves out the step of putting the plant in the new pot, keeps one invented line ("water
|
|
54
|
+
sparingly") from a discovery made before the check was tightened mid-run, and adds an uncited line.
|
|
55
|
+
|
|
56
|
+
## [1.43] - 2026-10-16
|
|
57
|
+
A comparison on 1.42 (SQLite or PostgreSQL for a small web application, then a recommendation for a hobby
|
|
58
|
+
project) finished on its own in 13 turns and 38 minutes of model time, with all three parts covered and
|
|
59
|
+
cited. Two problems showed up:
|
|
60
|
+
### Fixed
|
|
61
|
+
- Shown a long page's outline, the model tried to jump to a section with a made-up anchor
|
|
62
|
+
(`NAVIGATE ...#sqlite-vs-postgresql`, then `NAVIGATE #sqlite-vs-postgresql`), using up a turn's retries.
|
|
63
|
+
NAVIGATE to a section of the current page is refused with a pointer to READ_MORE and the outline's part
|
|
64
|
+
number.
|
|
65
|
+
- The final result treated a case study's own workload ("sequential writes, never concurrently") as the
|
|
66
|
+
hobby project's, and based the recommendation on it. The final-result rules now say that a discovery about
|
|
67
|
+
one source's own situation describes that situation, not the objective's, and that details of the
|
|
68
|
+
objective's situation it does not state must not be assumed.
|
|
69
|
+
|
|
70
|
+
Replaying the final-result step on that run's discoveries: the case study is attributed to its own workload
|
|
71
|
+
and the recommendation is conditional on the project's write pattern. On the earlier list task the result
|
|
72
|
+
still gives exactly three cited items and no opinion.
|
|
73
|
+
|
|
74
|
+
## [1.42] - 2026-10-16
|
|
75
|
+
A new kind of task, explaining a topic (how photosynthesis works: what goes in and out, the two stages and
|
|
76
|
+
where each takes place), reproduced the main weakness: on 1.41 the agent read parts 2 to 5 of a page that had
|
|
77
|
+
the inputs, outputs and cell locations without recording anything until a progress check fired, marked parts
|
|
78
|
+
of the objective "not covered" that its own discoveries covered (and the reverse), and had not finished after
|
|
79
|
+
18 turns and 49 minutes of model time.
|
|
80
|
+
### Added
|
|
81
|
+
- Read-on check: moving on to a later part of a page with nothing recorded from the part shown now gets one
|
|
82
|
+
cheap retry asking the model to list each fact in that part that serves the objective (or "none"), and to
|
|
83
|
+
DISCOVER any that are new; repeating the READ_MORE then goes through. Going back to an earlier part is left
|
|
84
|
+
to the re-read check, and a duplicate DISCOVER from the part counts as having weighed it.
|
|
85
|
+
- Discoveries are numbered: in fresh prompts, in the note after each DISCOVER ("recorded as discovery [3]"),
|
|
86
|
+
and in a shortened numbered list that now comes with the finish, progress and objective checks. The
|
|
87
|
+
part-by-part check asks for the number of the discovery covering each part ("covered by [2]").
|
|
88
|
+
### Changed
|
|
89
|
+
- READ_MORE for the part already shown is refused (with the number of the next part) instead of showing the
|
|
90
|
+
same part again and costing a turn.
|
|
91
|
+
|
|
92
|
+
Rerun on the same objective: finished on turn 18 with a cited explanation covering every part (inputs and
|
|
93
|
+
outputs, both stages, thylakoid membrane and stroma), in 44.8 minutes of model time. The read-on check fired
|
|
94
|
+
five times at 25 to 44 s each; every "none" it got was correct, and it used the page outline to jump to the
|
|
95
|
+
relevant parts of an 11-part page.
|
|
96
|
+
|
|
7
97
|
## [1.41] - 2026-10-15
|
|
8
98
|
### Added
|
|
9
99
|
- Published on PyPI: `pip install localagentbrowser`, then `python -m playwright install chromium` and run
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localagentbrowser
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.47
|
|
4
4
|
Summary: An autonomous web agent driven by a local LLM (LM Studio) and Playwright. Runs entirely on your own machine.
|
|
5
5
|
Author: SnowFX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,8 +56,11 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
56
56
|
- ⚡ **Built for slow hardware.** It reuses LM Studio's prompt cache, so most turns process only what changed.
|
|
57
57
|
- 📝 **A readable report every time.** `research_report.md` holds the final result and every finding with its
|
|
58
58
|
source, written however the run ends; `research_log.txt` keeps every action and reason.
|
|
59
|
-
- ✅ **Tested.**
|
|
60
|
-
plus live runs on
|
|
59
|
+
- ✅ **Tested.** 107 tests on Linux and Windows, Python 3.10–3.13, run in about a second with no model or browser,
|
|
60
|
+
plus live runs on seven different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
|
|
61
|
+
|
|
62
|
+
📖 **The story behind it:** [Teaching a 7B model on a CPU to finish what it starts](https://dev.to/snowfxxx/teaching-a-7b-model-on-a-cpu-to-finish-what-it-starts-1amc),
|
|
63
|
+
six lessons from 30 versions of keeping a small local model on track.
|
|
61
64
|
|
|
62
65
|
## Contents
|
|
63
66
|
|
|
@@ -83,6 +86,24 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
83
86
|
localagentbrowser
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
It asks for the objective, opens a Chromium window and starts its first turn:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
🤖 ==================================================== 🤖
|
|
93
|
+
🚀 Initializing Autonomous Discovery Agent Setup...
|
|
94
|
+
🎯 Define the Agent's High-Level Objective: What is the tallest mountain in Africa, and how tall is it in meters and in feet?
|
|
95
|
+
|
|
96
|
+
🧠 [TURN 1/100] Active reasoning cycle executing...
|
|
97
|
+
🔍 Scraping and preparing current environment text layout...
|
|
98
|
+
💡 Agent Thoughts: I need to find out the tallest mountain in Africa and its height in meters and feet.
|
|
99
|
+
I should start by searching for this information.
|
|
100
|
+
🎬 Chosen Action: [SEARCH] -> Parameter: tallest mountain in Africa
|
|
101
|
+
🌐 Driving Playwright to cross-domain target: https://html.duckduckgo.com/html/?q=tallest+mountain+in+Africa
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each turn takes from under a minute to a few minutes on a CPU (see [Performance](#performance)); the
|
|
105
|
+
[example run](#what-a-run-looks-like) below shows how it continues.
|
|
106
|
+
|
|
86
107
|
When it finishes, read `research_report.md` in the folder you ran it from. Press Ctrl+C at any time to
|
|
87
108
|
stop; the report is still written with everything found so far.
|
|
88
109
|
|
|
@@ -165,10 +186,13 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
|
|
|
165
186
|
| Documentation research | Setup commands and file layout for Playwright's test agents | 1.27 | Finished on its own in 13 turns (~36 min) with commands, roles and file layout |
|
|
166
187
|
| Documentation research, strict | The same, but "do not FINISH until fully validated", including "parameter schemas" | 1.38–1.40 | Its most detailed findings (per-client setup, seed test, agent definition files), but it keeps searching for schemas the docs do not contain and has not finished within an hour |
|
|
167
188
|
| Opinion from several sources | How spiritual traditions view cats, then form an opinion | 1.30 | Six traditions from two sources and a stated opinion, in 18 turns (37 min of model time) |
|
|
168
|
-
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40 | Correct, cited answer in 4 turns (about 12 min of model time) in
|
|
169
|
-
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 |
|
|
189
|
+
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40, 1.46 | Correct, cited answer in 4 turns (about 12 min of model time) in all three versions; before 1.35 it kept reading after finding it |
|
|
190
|
+
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner. Later: SQLite vs PostgreSQL for a small web app, then a recommendation for a hobby project | 1.36, 1.42 | Python vs Rust: cited comparison recommending Python in 16 turns (33 min), Rust's libraries not covered. SQLite vs PostgreSQL: all three parts covered and cited, with a recommendation, in 13 turns (38 min) |
|
|
170
191
|
| List of items | Three free, open-source Photoshop alternatives and the strengths of each | 1.37–1.38 | GIMP, Krita and Darktable with their strengths, all cited, in 9 turns (24 min); 1.37 listed a fourth and added an unrequested opinion, which 1.38 fixes |
|
|
192
|
+
| List of items | Three free, open-source alternatives to Microsoft Office and what each is best at | 1.46 | LibreOffice and OnlyOffice with their strengths, cited, in 18 turns (49 min); the third, FreeOffice, is free but not open source, and LibreOffice was recorded four times in different words |
|
|
171
193
|
| Step-by-step how-to | Make a sourdough starter, with quantities and what to do each day | 1.36–1.37 | A clear plan in 7 turns (19 min), but it invented the first water amount; 1.37 stops guessing and flags unsupported numbers, and the agent then went looking for the missing details |
|
|
194
|
+
| Step-by-step how-to | Repot a houseplant: what you need, the steps, and care for the first two weeks | 1.43–1.44 | 1.43 recorded six steps it wrote itself before reading the part that had them; 1.44 checks findings against the page and recorded the guide's real materials, steps and aftercare, finishing in 24 turns (68 min), though the final result left out one step |
|
|
195
|
+
| Explanation of a topic | How photosynthesis works: inputs and outputs, both stages and where each takes place | 1.41–1.42 | A complete, cited explanation in 18 turns (45 min); 1.41 read four parts of a relevant page without recording them and had not finished after 49 min |
|
|
172
196
|
|
|
173
197
|
## How it works
|
|
174
198
|
|
|
@@ -201,8 +225,14 @@ flowchart LR
|
|
|
201
225
|
Duplicate findings are recognised even when reworded into different lines, punctuation or order.
|
|
202
226
|
- **Cheap retries.** A refused reply is corrected inside the same turn by appending a short note to the
|
|
203
227
|
conversation, instead of wasting a whole turn.
|
|
204
|
-
- **Confirm before leaving.** Leaving a page without recording anything from it triggers one
|
|
205
|
-
something, or repeat the action to leave.
|
|
228
|
+
- **Confirm before leaving or reading on.** Leaving a page without recording anything from it triggers one
|
|
229
|
+
check: record something, or repeat the action to leave. Moving on to the next part of a page works the same
|
|
230
|
+
way, and the model must first list what the part it is on says about the objective (or "none").
|
|
231
|
+
- **Findings must match the page.** A finding that shares too few word pairs with the page text shown so far
|
|
232
|
+
is sent back once, asking for only what the page says in its own words: a small model will otherwise
|
|
233
|
+
"complete" a list from its own knowledge when a page says "follow these 6 steps" and the steps come later.
|
|
234
|
+
- **Numbered findings.** Findings are numbered in the prompt and listed with every check, and the model names
|
|
235
|
+
the finding that covers each part of the objective ("covered by [2]") instead of just saying "covered".
|
|
206
236
|
- **Knowing when it's done.** After every new finding the model is asked whether the objective is now fully
|
|
207
237
|
covered, and if so to finish instead of reading on for extra details.
|
|
208
238
|
- **Progress and objective checks.** After 4 turns without a new finding, and every 8 turns regardless, the
|
|
@@ -285,11 +315,13 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
285
315
|
|
|
286
316
|
## Limitations
|
|
287
317
|
|
|
288
|
-
- **Tried on
|
|
289
|
-
research, an opinion from several sources, a factual question, a comparison, a how-to
|
|
318
|
+
- **Tried on seven kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
|
|
319
|
+
research, an opinion from several sources, a factual question, a comparison, a how-to, a list and an
|
|
320
|
+
explanation of a topic. Each one
|
|
290
321
|
exposed something that did not generalise and was fixed; other kinds of tasks may expose more.
|
|
291
|
-
- **Reading without recording.** The model
|
|
292
|
-
relevant parts
|
|
322
|
+
- **Reading without recording.** The model used to read long pages part by part without recording the
|
|
323
|
+
relevant parts. Since 1.42 it must say what a part contains before reading on, which fixed this on an
|
|
324
|
+
explanation task; it is still the area most likely to need more work on other pages.
|
|
293
325
|
- **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
|
|
294
326
|
secondary sources or stays with one source too long, and results vary with the first pages a search turns
|
|
295
327
|
up; every finding is in `research_report.md` regardless.
|
|
@@ -297,8 +329,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
297
329
|
"fully validated" can keep the agent looking for something the web may not contain. It is told to finish
|
|
298
330
|
and name what it could not find after looking in several places, but a 7B model does not always judge
|
|
299
331
|
when that point has come; the turn limit (100) and Ctrl+C still apply, and the report is always current.
|
|
300
|
-
- **Check the citations.** The final result only
|
|
301
|
-
wrong number on a citation,
|
|
332
|
+
- **Check the citations.** The final result is told to use only recorded findings, but a 7B model sometimes
|
|
333
|
+
puts the wrong number on a citation, adds a line no finding supports or leaves out a step, so verify a
|
|
334
|
+
claim against the listed findings before relying on it.
|
|
302
335
|
- **Some sites refuse automated browsers** (HTTP 403). The agent respects that and does not try to disguise
|
|
303
336
|
itself; it moves on to other sources.
|
|
304
337
|
- **Reading only.** The agent searches, opens pages and reads them. It does not click, fill in forms or sign in.
|
|
@@ -311,9 +344,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
311
344
|
- [x] Include every finding in the final report, not only the model's own summary (1.29)
|
|
312
345
|
- [x] Write the final result in a separate, rule-bound step and flag unsupported numbers (1.32–1.38)
|
|
313
346
|
- [x] Finish as soon as the objective is covered (1.35)
|
|
314
|
-
- [
|
|
347
|
+
- [x] Get the model to weigh each part of a long page before reading on (1.42)
|
|
315
348
|
- [ ] Let very strict objectives end with the gaps named instead of searching indefinitely
|
|
316
|
-
- [ ] Try more kinds of objectives (
|
|
349
|
+
- [ ] Try more kinds of objectives (seven so far) and adjust the rules that don't generalise
|
|
317
350
|
- [ ] Make fresh prompts (the slowest kind of turn) cheaper still (31% cheaper since 1.34)
|
|
318
351
|
- [ ] Interacting with pages (clicking, forms) for tasks beyond reading
|
|
319
352
|
|
|
@@ -24,8 +24,11 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
24
24
|
- ⚡ **Built for slow hardware.** It reuses LM Studio's prompt cache, so most turns process only what changed.
|
|
25
25
|
- 📝 **A readable report every time.** `research_report.md` holds the final result and every finding with its
|
|
26
26
|
source, written however the run ends; `research_log.txt` keeps every action and reason.
|
|
27
|
-
- ✅ **Tested.**
|
|
28
|
-
plus live runs on
|
|
27
|
+
- ✅ **Tested.** 107 tests on Linux and Windows, Python 3.10–3.13, run in about a second with no model or browser,
|
|
28
|
+
plus live runs on seven different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
|
|
29
|
+
|
|
30
|
+
📖 **The story behind it:** [Teaching a 7B model on a CPU to finish what it starts](https://dev.to/snowfxxx/teaching-a-7b-model-on-a-cpu-to-finish-what-it-starts-1amc),
|
|
31
|
+
six lessons from 30 versions of keeping a small local model on track.
|
|
29
32
|
|
|
30
33
|
## Contents
|
|
31
34
|
|
|
@@ -51,6 +54,24 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
51
54
|
localagentbrowser
|
|
52
55
|
```
|
|
53
56
|
|
|
57
|
+
It asks for the objective, opens a Chromium window and starts its first turn:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
🤖 ==================================================== 🤖
|
|
61
|
+
🚀 Initializing Autonomous Discovery Agent Setup...
|
|
62
|
+
🎯 Define the Agent's High-Level Objective: What is the tallest mountain in Africa, and how tall is it in meters and in feet?
|
|
63
|
+
|
|
64
|
+
🧠 [TURN 1/100] Active reasoning cycle executing...
|
|
65
|
+
🔍 Scraping and preparing current environment text layout...
|
|
66
|
+
💡 Agent Thoughts: I need to find out the tallest mountain in Africa and its height in meters and feet.
|
|
67
|
+
I should start by searching for this information.
|
|
68
|
+
🎬 Chosen Action: [SEARCH] -> Parameter: tallest mountain in Africa
|
|
69
|
+
🌐 Driving Playwright to cross-domain target: https://html.duckduckgo.com/html/?q=tallest+mountain+in+Africa
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each turn takes from under a minute to a few minutes on a CPU (see [Performance](#performance)); the
|
|
73
|
+
[example run](#what-a-run-looks-like) below shows how it continues.
|
|
74
|
+
|
|
54
75
|
When it finishes, read `research_report.md` in the folder you ran it from. Press Ctrl+C at any time to
|
|
55
76
|
stop; the report is still written with everything found so far.
|
|
56
77
|
|
|
@@ -133,10 +154,13 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
|
|
|
133
154
|
| Documentation research | Setup commands and file layout for Playwright's test agents | 1.27 | Finished on its own in 13 turns (~36 min) with commands, roles and file layout |
|
|
134
155
|
| Documentation research, strict | The same, but "do not FINISH until fully validated", including "parameter schemas" | 1.38–1.40 | Its most detailed findings (per-client setup, seed test, agent definition files), but it keeps searching for schemas the docs do not contain and has not finished within an hour |
|
|
135
156
|
| Opinion from several sources | How spiritual traditions view cats, then form an opinion | 1.30 | Six traditions from two sources and a stated opinion, in 18 turns (37 min of model time) |
|
|
136
|
-
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40 | Correct, cited answer in 4 turns (about 12 min of model time) in
|
|
137
|
-
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 |
|
|
157
|
+
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40, 1.46 | Correct, cited answer in 4 turns (about 12 min of model time) in all three versions; before 1.35 it kept reading after finding it |
|
|
158
|
+
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner. Later: SQLite vs PostgreSQL for a small web app, then a recommendation for a hobby project | 1.36, 1.42 | Python vs Rust: cited comparison recommending Python in 16 turns (33 min), Rust's libraries not covered. SQLite vs PostgreSQL: all three parts covered and cited, with a recommendation, in 13 turns (38 min) |
|
|
138
159
|
| List of items | Three free, open-source Photoshop alternatives and the strengths of each | 1.37–1.38 | GIMP, Krita and Darktable with their strengths, all cited, in 9 turns (24 min); 1.37 listed a fourth and added an unrequested opinion, which 1.38 fixes |
|
|
160
|
+
| List of items | Three free, open-source alternatives to Microsoft Office and what each is best at | 1.46 | LibreOffice and OnlyOffice with their strengths, cited, in 18 turns (49 min); the third, FreeOffice, is free but not open source, and LibreOffice was recorded four times in different words |
|
|
139
161
|
| Step-by-step how-to | Make a sourdough starter, with quantities and what to do each day | 1.36–1.37 | A clear plan in 7 turns (19 min), but it invented the first water amount; 1.37 stops guessing and flags unsupported numbers, and the agent then went looking for the missing details |
|
|
162
|
+
| Step-by-step how-to | Repot a houseplant: what you need, the steps, and care for the first two weeks | 1.43–1.44 | 1.43 recorded six steps it wrote itself before reading the part that had them; 1.44 checks findings against the page and recorded the guide's real materials, steps and aftercare, finishing in 24 turns (68 min), though the final result left out one step |
|
|
163
|
+
| Explanation of a topic | How photosynthesis works: inputs and outputs, both stages and where each takes place | 1.41–1.42 | A complete, cited explanation in 18 turns (45 min); 1.41 read four parts of a relevant page without recording them and had not finished after 49 min |
|
|
140
164
|
|
|
141
165
|
## How it works
|
|
142
166
|
|
|
@@ -169,8 +193,14 @@ flowchart LR
|
|
|
169
193
|
Duplicate findings are recognised even when reworded into different lines, punctuation or order.
|
|
170
194
|
- **Cheap retries.** A refused reply is corrected inside the same turn by appending a short note to the
|
|
171
195
|
conversation, instead of wasting a whole turn.
|
|
172
|
-
- **Confirm before leaving.** Leaving a page without recording anything from it triggers one
|
|
173
|
-
something, or repeat the action to leave.
|
|
196
|
+
- **Confirm before leaving or reading on.** Leaving a page without recording anything from it triggers one
|
|
197
|
+
check: record something, or repeat the action to leave. Moving on to the next part of a page works the same
|
|
198
|
+
way, and the model must first list what the part it is on says about the objective (or "none").
|
|
199
|
+
- **Findings must match the page.** A finding that shares too few word pairs with the page text shown so far
|
|
200
|
+
is sent back once, asking for only what the page says in its own words: a small model will otherwise
|
|
201
|
+
"complete" a list from its own knowledge when a page says "follow these 6 steps" and the steps come later.
|
|
202
|
+
- **Numbered findings.** Findings are numbered in the prompt and listed with every check, and the model names
|
|
203
|
+
the finding that covers each part of the objective ("covered by [2]") instead of just saying "covered".
|
|
174
204
|
- **Knowing when it's done.** After every new finding the model is asked whether the objective is now fully
|
|
175
205
|
covered, and if so to finish instead of reading on for extra details.
|
|
176
206
|
- **Progress and objective checks.** After 4 turns without a new finding, and every 8 turns regardless, the
|
|
@@ -253,11 +283,13 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
253
283
|
|
|
254
284
|
## Limitations
|
|
255
285
|
|
|
256
|
-
- **Tried on
|
|
257
|
-
research, an opinion from several sources, a factual question, a comparison, a how-to
|
|
286
|
+
- **Tried on seven kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
|
|
287
|
+
research, an opinion from several sources, a factual question, a comparison, a how-to, a list and an
|
|
288
|
+
explanation of a topic. Each one
|
|
258
289
|
exposed something that did not generalise and was fixed; other kinds of tasks may expose more.
|
|
259
|
-
- **Reading without recording.** The model
|
|
260
|
-
relevant parts
|
|
290
|
+
- **Reading without recording.** The model used to read long pages part by part without recording the
|
|
291
|
+
relevant parts. Since 1.42 it must say what a part contains before reading on, which fixed this on an
|
|
292
|
+
explanation task; it is still the area most likely to need more work on other pages.
|
|
261
293
|
- **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
|
|
262
294
|
secondary sources or stays with one source too long, and results vary with the first pages a search turns
|
|
263
295
|
up; every finding is in `research_report.md` regardless.
|
|
@@ -265,8 +297,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
265
297
|
"fully validated" can keep the agent looking for something the web may not contain. It is told to finish
|
|
266
298
|
and name what it could not find after looking in several places, but a 7B model does not always judge
|
|
267
299
|
when that point has come; the turn limit (100) and Ctrl+C still apply, and the report is always current.
|
|
268
|
-
- **Check the citations.** The final result only
|
|
269
|
-
wrong number on a citation,
|
|
300
|
+
- **Check the citations.** The final result is told to use only recorded findings, but a 7B model sometimes
|
|
301
|
+
puts the wrong number on a citation, adds a line no finding supports or leaves out a step, so verify a
|
|
302
|
+
claim against the listed findings before relying on it.
|
|
270
303
|
- **Some sites refuse automated browsers** (HTTP 403). The agent respects that and does not try to disguise
|
|
271
304
|
itself; it moves on to other sources.
|
|
272
305
|
- **Reading only.** The agent searches, opens pages and reads them. It does not click, fill in forms or sign in.
|
|
@@ -279,9 +312,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
279
312
|
- [x] Include every finding in the final report, not only the model's own summary (1.29)
|
|
280
313
|
- [x] Write the final result in a separate, rule-bound step and flag unsupported numbers (1.32–1.38)
|
|
281
314
|
- [x] Finish as soon as the objective is covered (1.35)
|
|
282
|
-
- [
|
|
315
|
+
- [x] Get the model to weigh each part of a long page before reading on (1.42)
|
|
283
316
|
- [ ] Let very strict objectives end with the gaps named instead of searching indefinitely
|
|
284
|
-
- [ ] Try more kinds of objectives (
|
|
317
|
+
- [ ] Try more kinds of objectives (seven so far) and adjust the rules that don't generalise
|
|
285
318
|
- [ ] Make fresh prompts (the slowest kind of turn) cheaper still (31% cheaper since 1.34)
|
|
286
319
|
- [ ] Interacting with pages (clicking, forms) for tasks beyond reading
|
|
287
320
|
|
|
@@ -46,6 +46,9 @@ MAX_CHEAP_RETRIES = 2 # Refused replies re-asked within the same turn before th
|
|
|
46
46
|
MAX_FOLLOWUPS = 12 # Turns that continue one conversation before a fresh full prompt (the token budget usually ends it first)
|
|
47
47
|
MAX_CONVERSATION_TOKENS = int(CONTEXT_LENGTH * 0.75) # Start fresh with room left for the next page and the reply
|
|
48
48
|
MAX_DISCOVERY_PROMPT_CHARS = 250 # Discoveries are shortened to this in fresh prompts; the log keeps the full text
|
|
49
|
+
MAX_CHECK_DISCOVERY_CHARS = 150 # Shorter still in the numbered list that comes with the finish and progress checks
|
|
50
|
+
GROUNDED_PAIR_SHARE = 0.7 # A DISCOVER sharing fewer of its word pairs with the page text shown is confirmed first
|
|
51
|
+
MIN_GROUNDING_PAIRS = 20 # Shorter discoveries are not checked: a few connecting words sink their score
|
|
49
52
|
TURN_PAUSE_SECONDS = 1
|
|
50
53
|
STALL_TURNS = 4 # Turns without a new discovery before the model is asked to check its progress
|
|
51
54
|
COMPOSE_MAX_TOKENS = 900 # Length limit for the separately written final result
|
|
@@ -53,8 +56,8 @@ OBJECTIVE_CHECK_EVERY = 8 # Turns between scheduled objective checks, which happ
|
|
|
53
56
|
READ_FIRST = ("First, if the CURRENT ENVIRONMENT (or an unread part of this page, via READ_MORE) contains information "
|
|
54
57
|
"relevant to the objective that is not yet in DISCOVERIES SO FAR, DISCOVER it now.")
|
|
55
58
|
CHECK_PARTS = ("split the OBJECTIVE into its separate parts and, in thought_process, write each part followed by the "
|
|
56
|
-
"discovery that covers it, or 'not covered'. Only parts
|
|
57
|
-
"or summary are 'written at FINISH' (write them in the FINISH parameter, never as a DISCOVER); information "
|
|
59
|
+
"number of the discovery that covers it (for example 'covered by [2]'), or 'not covered'. Only parts "
|
|
60
|
+
"that ask for your own answer, opinion, recommendation or summary are 'written at FINISH' (write them in the FINISH parameter, never as a DISCOVER); information "
|
|
58
61
|
"the objective asks you to find is never 'written at FINISH': it is covered by a discovery or 'not covered'.")
|
|
59
62
|
FINAL_RESULT = ("the complete final result the objective asks for (for example the answer, opinion, summary or "
|
|
60
63
|
"deliverable, not just a list of what you found)")
|
|
@@ -349,7 +352,11 @@ async def build_environment(page, current_url, page_state, visited_urls=frozense
|
|
|
349
352
|
if page_state["url"] != normalize_url(current_url):
|
|
350
353
|
parts, outline = await extract_page_parts(page)
|
|
351
354
|
page_state.update(url=normalize_url(current_url), parts=parts, outline=outline, index=0,
|
|
352
|
-
seen_parts={0}, visit_discoveries=0, leave_confirmed=False, reread_confirmed=set()
|
|
355
|
+
seen_parts={0}, visit_discoveries=0, leave_confirmed=False, reread_confirmed=set(),
|
|
356
|
+
read_on_confirmed=set(), grounding_asked=[],
|
|
357
|
+
# Kept per URL: on a return visit the read-on check asked about a part already recorded
|
|
358
|
+
recorded_parts=page_state.setdefault("recorded_by_url", {}).setdefault(
|
|
359
|
+
normalize_url(current_url), set()))
|
|
353
360
|
link_lines = await extract_page_links(page, shown_links)
|
|
354
361
|
return f"{render_part(page_state, with_outline=True)}\n\nLinks On Page:\n{link_lines or '[None]'}"
|
|
355
362
|
except Exception as e:
|
|
@@ -360,7 +367,9 @@ def render_part(page_state, with_outline=False):
|
|
|
360
367
|
if not parts:
|
|
361
368
|
return "Page Text:\n[No readable text]"
|
|
362
369
|
if index < len(parts) - 1:
|
|
363
|
-
|
|
370
|
+
# A part ending "Just follow these 6 easy steps" led the model to write the steps itself
|
|
371
|
+
footer = (f"[Page continues: READ_MORE shows part {index + 2} of {len(parts)}. "
|
|
372
|
+
"What comes next on the page has not been shown to you yet.]")
|
|
364
373
|
else:
|
|
365
374
|
footer = "[End of page]"
|
|
366
375
|
text = f"Page Text (part {index + 1} of {len(parts)}):\n{parts[index]}\n{footer}"
|
|
@@ -393,6 +402,42 @@ def discovery_lines(text):
|
|
|
393
402
|
text = unicodedata.normalize("NFKC", str(text)).translate(QUOTE_STYLES)
|
|
394
403
|
return {" ".join(line.lower().split()) for line in text.splitlines() if line.strip()}
|
|
395
404
|
|
|
405
|
+
def grounded_share(text, parts):
|
|
406
|
+
"""The share of a discovery's consecutive word pairs that also appear in the given page parts."""
|
|
407
|
+
mine = word_pairs(str(text).lower())
|
|
408
|
+
return len(mine & word_pairs(" ".join(parts).lower())) / len(mine) if mine else 1.0
|
|
409
|
+
|
|
410
|
+
def grounding_check(parameter, page_state, current_url, objective=""):
|
|
411
|
+
# Shown part 2 of a guide, which ended "Just follow these 6 easy steps", the model recorded six steps it
|
|
412
|
+
# wrote itself (the real ones were in part 3), then a care routine the page never gave. Real discoveries
|
|
413
|
+
# from earlier runs shared 64-100% of their word pairs with the page; these shared 11-44%, and a rerun's
|
|
414
|
+
# mix of real pot-size advice with invented steps shared 57%. A DISCOVER that matches the parts shown less
|
|
415
|
+
# than that gets one cheap retry; repeating it then goes through, since a heavy paraphrase can score low too.
|
|
416
|
+
parts = page_state.get("parts") or []
|
|
417
|
+
if not (current_url and page_state.get("url") == normalize_url(current_url) and parts):
|
|
418
|
+
return None
|
|
419
|
+
if len(word_pairs(parameter)) < MIN_GROUNDING_PAIRS:
|
|
420
|
+
return None
|
|
421
|
+
# The objective's wording counts as well: "Mount Kilimanjaro is the tallest mountain in Africa..." echoed the
|
|
422
|
+
# question's "tallest" where the page says "highest", and was sent back although it was accurate
|
|
423
|
+
shown = [parts[i] for i in sorted(page_state.get("seen_parts", {0})) if i < len(parts)] + [objective]
|
|
424
|
+
if grounded_share(parameter, shown) >= GROUNDED_PAIR_SHARE:
|
|
425
|
+
return None
|
|
426
|
+
# Only repeating the same DISCOVER confirms it: asked again, the model once sent a different text that was
|
|
427
|
+
# still invented ("water sparingly", which the page never said), and a per-part pass let it through
|
|
428
|
+
asked = page_state.setdefault("grounding_asked", [])
|
|
429
|
+
mine = word_pairs(parameter.lower())
|
|
430
|
+
if any(len(mine & earlier) >= 0.8 * len(mine) for earlier in asked):
|
|
431
|
+
return None
|
|
432
|
+
asked.append(mine)
|
|
433
|
+
# Pointed to READ_MORE as well, the model left a part holding the real materials list and steps unrecorded
|
|
434
|
+
return "CONFIRM_GROUNDED", (f"Much of that DISCOVER does not match the page text you have been shown, so part of it "
|
|
435
|
+
"seems to come from your own knowledge. DISCOVER again with only what the page actually "
|
|
436
|
+
f"says in part {page_state['index'] + 1} (shown now) or the parts before it, using the "
|
|
437
|
+
"page's own wording, for example its lists and steps. Do not add steps, amounts or "
|
|
438
|
+
"advice that the page does not give. If your DISCOVER really is what the page says, "
|
|
439
|
+
"repeat it.")
|
|
440
|
+
|
|
396
441
|
def word_pairs(text):
|
|
397
442
|
words = re.findall(r"\w+", text)
|
|
398
443
|
return {tuple(words[i:i + 2]) for i in range(len(words) - 1)} or ({tuple(words)} if words else set())
|
|
@@ -455,6 +500,25 @@ def leave_check(action, page_state, current_url, journal=()):
|
|
|
455
500
|
f"If it has information relevant to the objective, DISCOVER it now{read_more}. "
|
|
456
501
|
f"If it truly has nothing relevant, repeat your {action} to leave.")
|
|
457
502
|
|
|
503
|
+
def read_on_check(page_state, current_url, target):
|
|
504
|
+
# The model read long pages part after part without recording anything, even though every prompt ends by
|
|
505
|
+
# asking it to DISCOVER relevant information first: on an explanation task it read four parts of a page that
|
|
506
|
+
# had the inputs, outputs and cell locations it needed and recorded them only when a progress check fired.
|
|
507
|
+
# Moving on from a part nothing was recorded from gets one cheap retry that makes the model name what the
|
|
508
|
+
# part says about the objective; repeating the READ_MORE then goes through.
|
|
509
|
+
index = page_state["index"]
|
|
510
|
+
# Only moving forward counts: going back to an earlier part is covered by the re-read check
|
|
511
|
+
if target <= index or index in page_state.get("recorded_parts", set()):
|
|
512
|
+
return None
|
|
513
|
+
confirmed = page_state.setdefault("read_on_confirmed", set())
|
|
514
|
+
if index in confirmed:
|
|
515
|
+
return None
|
|
516
|
+
confirmed.add(index)
|
|
517
|
+
return "CONFIRM_READ_ON", (f"Before reading on: you have recorded nothing from part {index + 1}, which is shown now. "
|
|
518
|
+
"In thought_process, list each fact in it that serves the OBJECTIVE, or write 'none'. If any "
|
|
519
|
+
"of them are not in DISCOVERIES SO FAR, DISCOVER them now. If there are none, repeat your "
|
|
520
|
+
"READ_MORE.")
|
|
521
|
+
|
|
458
522
|
def gap_check(thought, tried):
|
|
459
523
|
# The model is asked to write 'not covered' for missing parts; on a comparison it wrote "Available Libraries:
|
|
460
524
|
# Not covered" and finished anyway. A FINISH whose own reasoning says that is sent back to work on the gap,
|
|
@@ -489,8 +553,13 @@ def check_decision(action, parameter, journal, tried, page_state, current_url, t
|
|
|
489
553
|
if not parameter.strip():
|
|
490
554
|
return "BLOCKED_NAVIGATE", "NAVIGATE needs a full URL in the parameter."
|
|
491
555
|
target = normalize_url(determine_url(action, parameter))
|
|
492
|
-
|
|
493
|
-
|
|
556
|
+
# Shown a long page's outline, the model kept trying to jump to a section with a made-up anchor
|
|
557
|
+
# ("NAVIGATE ...#sqlite-vs-postgresql", then "NAVIGATE #sqlite-vs-postgresql")
|
|
558
|
+
section = "#" in parameter and (parameter.strip().startswith("#") or target == normalize_url(current_url or ""))
|
|
559
|
+
if current_url and (target == normalize_url(current_url) or section):
|
|
560
|
+
jump = (" To go to a section of this page, use READ_MORE with that section's part number from the outline."
|
|
561
|
+
if section else "")
|
|
562
|
+
return "BLOCKED_NAVIGATE", (f"{where_you_are(page_state, current_url)} You are already on this page.{jump} "
|
|
494
563
|
"Use READ_MORE to see its other parts, DISCOVER from it, or go somewhere else.")
|
|
495
564
|
# Going back to an earlier page is allowed; the cap stops back-and-forth loops between pages
|
|
496
565
|
if [normalize_url(u) for u in tried["urls"]].count(target) >= MAX_VISITS_PER_URL:
|
|
@@ -505,6 +574,12 @@ def check_decision(action, parameter, journal, tried, page_state, current_url, t
|
|
|
505
574
|
if not 0 <= target < total:
|
|
506
575
|
return "BLOCKED_READ_MORE", (f"There is no part {target + 1}. {where_you_are(page_state, current_url)} "
|
|
507
576
|
f"This page has {total} part(s).")
|
|
577
|
+
# On part 3 the model asked for "READ_MORE 3", which only showed the same part again and cost a turn
|
|
578
|
+
if target == page_state["index"]:
|
|
579
|
+
following = (f"For part {target + 2}, use READ_MORE with an empty parameter. "
|
|
580
|
+
if target < total - 1 else "It is the last part of this page. ")
|
|
581
|
+
return "BLOCKED_READ_MORE", (f"Part {target + 1} is the part already shown. {following}"
|
|
582
|
+
"Otherwise choose DISCOVER or another action.")
|
|
508
583
|
# Re-reading a part is allowed, but confirmed once per part (the same pattern as the leave check)
|
|
509
584
|
confirmed = page_state.setdefault("reread_confirmed", set())
|
|
510
585
|
if target in page_state.get("seen_parts", set()) and target != page_state["index"] and target not in confirmed:
|
|
@@ -512,11 +587,16 @@ def check_decision(action, parameter, journal, tried, page_state, current_url, t
|
|
|
512
587
|
return "CONFIRM_REREAD", (f"You already read part {target + 1} of this page; anything useful from it should "
|
|
513
588
|
"already be in DISCOVERIES SO FAR. Choose an unread part or another action, or "
|
|
514
589
|
"repeat this READ_MORE if you really need to read it again.")
|
|
590
|
+
return read_on_check(page_state, current_url, target)
|
|
515
591
|
elif action == "DISCOVER":
|
|
516
592
|
if not discovery_lines(parameter):
|
|
517
593
|
return "BLOCKED_DISCOVER", "The DISCOVER parameter was empty. Put the actual information in the parameter."
|
|
518
594
|
if is_known_discovery(parameter, journal):
|
|
595
|
+
# The model has weighed this part: without this, the read-on check asked it to DISCOVER again
|
|
596
|
+
# right after this refusal, and it repeated the duplicate until the turn was lost
|
|
597
|
+
page_state.setdefault("read_on_confirmed", set()).add(page_state.get("index", 0))
|
|
519
598
|
return "DUPLICATE_DISCOVER", "Everything in that DISCOVER is already recorded in DISCOVERIES SO FAR."
|
|
599
|
+
return grounding_check(parameter, page_state, current_url, tried.get("objective", ""))
|
|
520
600
|
elif action == "FINISH":
|
|
521
601
|
return finish_check(tried, journal, page_state, current_url) or gap_check(thought, tried)
|
|
522
602
|
else:
|
|
@@ -539,7 +619,21 @@ def finish_check(tried, journal, page_state=None, current_url=None):
|
|
|
539
619
|
if tried.get("finish_confirmed"):
|
|
540
620
|
return None
|
|
541
621
|
tried["finish_confirmed"] = True # Reset at the start of every turn
|
|
542
|
-
return "CONFIRM_FINISH", f"{source_note(journal)}{unread_note(page_state or {}, current_url)}
|
|
622
|
+
return "CONFIRM_FINISH", (f"{source_note(journal)}{unread_note(page_state or {}, current_url)}"
|
|
623
|
+
f"{numbered_discoveries(journal)} {FINISH_CHECK}")
|
|
624
|
+
|
|
625
|
+
def numbered_discoveries(journal, limit=MAX_CHECK_DISCOVERY_CHARS):
|
|
626
|
+
# Asked to name the discovery covering each part of the objective, the model marked the light reactions
|
|
627
|
+
# "not covered" although its first discovery described them: discoveries were an unnumbered list, far back
|
|
628
|
+
# in the prompt. Checks now carry the list, numbered, so each part can be matched to a number.
|
|
629
|
+
lines = numbered_lines(journal, limit)
|
|
630
|
+
if not lines:
|
|
631
|
+
return ""
|
|
632
|
+
return "\nYour discoveries, numbered:\n" + "\n".join(lines) + "\n"
|
|
633
|
+
|
|
634
|
+
def numbered_lines(journal, limit):
|
|
635
|
+
found = [e for e in journal if e.get("action") == "DISCOVER"]
|
|
636
|
+
return [f"[{i}] {shorten(e['extracted_data'], limit)}" for i, e in enumerate(found, 1)]
|
|
543
637
|
|
|
544
638
|
def unread_note(page_state, current_url):
|
|
545
639
|
# On a how-to the agent finished after 3 of 6 parts of the recipe, missing the amounts on the unread parts
|
|
@@ -693,7 +787,7 @@ async def build_full_prompt(page, objective, journal, current_url, tried, page_s
|
|
|
693
787
|
|
|
694
788
|
# Shortened in the prompt (the full text is in the log and the report, and duplicates are checked against
|
|
695
789
|
# it in code): the growing discoveries list had become a quarter of every fresh prompt
|
|
696
|
-
discoveries =
|
|
790
|
+
discoveries = "\n".join(numbered_lines(journal, MAX_DISCOVERY_PROMPT_CHARS)) or "[None yet]"
|
|
697
791
|
recent_journal = compact_journal(journal)
|
|
698
792
|
|
|
699
793
|
# LM Studio only reuses the part of the prompt that is identical to the previous turn's, up to the
|
|
@@ -704,7 +798,7 @@ async def build_full_prompt(page, objective, journal, current_url, tried, page_s
|
|
|
704
798
|
{objective}
|
|
705
799
|
|
|
706
800
|
### DISCOVERIES SO FAR
|
|
707
|
-
{
|
|
801
|
+
{discoveries}
|
|
708
802
|
|
|
709
803
|
### RECENT RESEARCH JOURNAL
|
|
710
804
|
{recent_journal}
|
|
@@ -769,7 +863,7 @@ async def request_decision(client, messages, journal, tried, page_state, current
|
|
|
769
863
|
refused.append({"action": action, "parameter": parameter, "journal_action": refusal[0], "reason": refusal[1]})
|
|
770
864
|
print(f"♻️ Cheap retry {len(refused)}/{MAX_CHEAP_RETRIES}: [{action}] refused. {refusal[1]}")
|
|
771
865
|
correction = refusal_correction(action, refusal[1], page_state, current_url)
|
|
772
|
-
if refusal[0]
|
|
866
|
+
if refusal[0].startswith("CONFIRM_"):
|
|
773
867
|
# Not a true refusal: repeating the action is an allowed answer, so no "do not repeat" wording
|
|
774
868
|
correction = f"CHECK: {refusal[1]} Reply with only the JSON object."
|
|
775
869
|
elif len(refused) >= 2:
|
|
@@ -789,7 +883,7 @@ def unverified_numbers(text, discoveries):
|
|
|
789
883
|
A small model writing a how-to filled a gap with an invented quantity ("45 grams of lukewarm water (about
|
|
790
884
|
1/4 cup)"); numbers are where such errors do the most harm and the easiest place to check them.
|
|
791
885
|
"""
|
|
792
|
-
text = re.sub(r"\[\d
|
|
886
|
+
text = re.sub(r"\[\d+(?:\s*[,;]\s*\d+)*\]", " ", str(text)) # Citation markers like [2] or [4, 5]
|
|
793
887
|
text = re.sub(r"(?m)^\s*\d+[.)]\s", " ", text) # Numbered list items
|
|
794
888
|
known = {n.replace(",", "") for e in discoveries for n in NUMBER_PATTERN.findall(e["extracted_data"])}
|
|
795
889
|
unverified = []
|
|
@@ -844,12 +938,15 @@ async def compose_final_result(client, objective, journal):
|
|
|
844
938
|
"1. Use only the information in the DISCOVERIES. Do not fill gaps from your own knowledge or with "
|
|
845
939
|
"guesses: no amounts, numbers, steps or advice that the discoveries do not contain. Where a detail the "
|
|
846
940
|
"objective asks for is missing, say that it was not found.\n"
|
|
847
|
-
"2.
|
|
848
|
-
"
|
|
941
|
+
"2. A discovery about one source's own situation (for example a company's workload in a case study) "
|
|
942
|
+
"describes that situation, not the one in the OBJECTIVE: say whose situation it is, and do not assume "
|
|
943
|
+
"details of the OBJECTIVE's situation that it does not state.\n"
|
|
944
|
+
"3. If the objective asks for a specific number of items, give exactly that many (the best supported).\n"
|
|
945
|
+
"4. Refer to the discoveries that support each point by their numbers, like [2], and only cite a number "
|
|
849
946
|
"for something that discovery actually says.\n"
|
|
850
|
-
"
|
|
947
|
+
"5. Do not add a 'My view:' paragraph unless the objective explicitly asks for your view, opinion or "
|
|
851
948
|
"recommendation; if it does, give it in a paragraph that starts with 'My view:'.\n"
|
|
852
|
-
"
|
|
949
|
+
"6. Write plain text, not JSON.")
|
|
853
950
|
# A fresh prompt with only the discoveries, not the browsing conversation: continuing that conversation let
|
|
854
951
|
# the model use page text it had read but not recorded, and cite discovery numbers that did not say it
|
|
855
952
|
messages = [{"role": "system", "content": COMPOSE_SYSTEM}, {"role": "user", "content": request}]
|
|
@@ -894,7 +991,7 @@ async def main():
|
|
|
894
991
|
current_url = None
|
|
895
992
|
turn_counter = 1
|
|
896
993
|
max_turns = 100
|
|
897
|
-
tried = {"queries": [], "urls": []}
|
|
994
|
+
tried = {"queries": [], "urls": [], "objective": objective}
|
|
898
995
|
page_state = {"url": None, "parts": [], "index": 0}
|
|
899
996
|
conversation = new_conversation()
|
|
900
997
|
loop_msg = None
|
|
@@ -1030,10 +1127,12 @@ async def main():
|
|
|
1030
1127
|
print(f"📝 Logging critical discovery element: {parameter}")
|
|
1031
1128
|
journal.append({"turn": turn_counter, "thought": thought, "action": "DISCOVER", "source": current_url, "extracted_data": parameter})
|
|
1032
1129
|
page_state["visit_discoveries"] = page_state.get("visit_discoveries", 0) + 1
|
|
1130
|
+
page_state.setdefault("recorded_parts", set()).add(page_state["index"])
|
|
1033
1131
|
# A discovery is when the objective can become complete; on a one-answer question the model
|
|
1034
1132
|
# recorded the full answer, said "the objective has been met", then kept reading for
|
|
1035
1133
|
# "interesting" details, so completion is checked right here
|
|
1036
|
-
|
|
1134
|
+
number = sum(1 for e in journal if e.get("action") == "DISCOVER")
|
|
1135
|
+
set_note(conversation, f"RESULT: Your DISCOVER was recorded as discovery [{number}] in DISCOVERIES SO FAR. "
|
|
1037
1136
|
f"{where_you_are(page_state, current_url)} The page is unchanged. "
|
|
1038
1137
|
"Do not record the same information again. If DISCOVERIES SO FAR now cover "
|
|
1039
1138
|
"every part of the OBJECTIVE, choose FINISH now; do not read further for extra "
|
|
@@ -1059,7 +1158,8 @@ async def main():
|
|
|
1059
1158
|
# the current page and pushed the model to navigate away without reading what was in front of it
|
|
1060
1159
|
if turns_without_discovery and turns_without_discovery % STALL_TURNS == 0:
|
|
1061
1160
|
print(f"⚠️ No new discoveries in {turns_without_discovery} turns. Adding progress check.")
|
|
1062
|
-
progress = (f"PROGRESS CHECK: no new discoveries in the last {turns_without_discovery} turns.
|
|
1161
|
+
progress = (f"PROGRESS CHECK: no new discoveries in the last {turns_without_discovery} turns."
|
|
1162
|
+
f"{numbered_discoveries(journal)} {READ_FIRST} "
|
|
1063
1163
|
f"Then {CHECK_PARTS} If every part is covered, choose FINISH with {FINAL_RESULT} in the "
|
|
1064
1164
|
"parameter. If not, state in thought_process exactly what is still missing, then go and find "
|
|
1065
1165
|
f"that specific thing. {GIVE_UP_RULE}")
|
|
@@ -1073,7 +1173,8 @@ async def main():
|
|
|
1073
1173
|
turns_since_objective_check = 0
|
|
1074
1174
|
count = sum(1 for e in journal if e.get("action") == "DISCOVER")
|
|
1075
1175
|
print(f"🧭 Objective check after {turn_counter} turns ({count} discoveries).")
|
|
1076
|
-
check = (f"OBJECTIVE CHECK: you have recorded {count} discoveries in {turn_counter} turns.
|
|
1176
|
+
check = (f"OBJECTIVE CHECK: you have recorded {count} discoveries in {turn_counter} turns."
|
|
1177
|
+
f"{numbered_discoveries(journal)} "
|
|
1077
1178
|
f"{source_note(journal)} {READ_FIRST} "
|
|
1078
1179
|
f"Then {CHECK_PARTS} If every part is covered, choose FINISH with {FINAL_RESULT} in the "
|
|
1079
1180
|
"parameter. If not, state in thought_process exactly what is still missing and go directly for it; "
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: localagentbrowser
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.47
|
|
4
4
|
Summary: An autonomous web agent driven by a local LLM (LM Studio) and Playwright. Runs entirely on your own machine.
|
|
5
5
|
Author: SnowFX
|
|
6
6
|
License-Expression: MIT
|
|
@@ -56,8 +56,11 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
56
56
|
- ⚡ **Built for slow hardware.** It reuses LM Studio's prompt cache, so most turns process only what changed.
|
|
57
57
|
- 📝 **A readable report every time.** `research_report.md` holds the final result and every finding with its
|
|
58
58
|
source, written however the run ends; `research_log.txt` keeps every action and reason.
|
|
59
|
-
- ✅ **Tested.**
|
|
60
|
-
plus live runs on
|
|
59
|
+
- ✅ **Tested.** 107 tests on Linux and Windows, Python 3.10–3.13, run in about a second with no model or browser,
|
|
60
|
+
plus live runs on seven different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
|
|
61
|
+
|
|
62
|
+
📖 **The story behind it:** [Teaching a 7B model on a CPU to finish what it starts](https://dev.to/snowfxxx/teaching-a-7b-model-on-a-cpu-to-finish-what-it-starts-1amc),
|
|
63
|
+
six lessons from 30 versions of keeping a small local model on track.
|
|
61
64
|
|
|
62
65
|
## Contents
|
|
63
66
|
|
|
@@ -83,6 +86,24 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
|
|
|
83
86
|
localagentbrowser
|
|
84
87
|
```
|
|
85
88
|
|
|
89
|
+
It asks for the objective, opens a Chromium window and starts its first turn:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
🤖 ==================================================== 🤖
|
|
93
|
+
🚀 Initializing Autonomous Discovery Agent Setup...
|
|
94
|
+
🎯 Define the Agent's High-Level Objective: What is the tallest mountain in Africa, and how tall is it in meters and in feet?
|
|
95
|
+
|
|
96
|
+
🧠 [TURN 1/100] Active reasoning cycle executing...
|
|
97
|
+
🔍 Scraping and preparing current environment text layout...
|
|
98
|
+
💡 Agent Thoughts: I need to find out the tallest mountain in Africa and its height in meters and feet.
|
|
99
|
+
I should start by searching for this information.
|
|
100
|
+
🎬 Chosen Action: [SEARCH] -> Parameter: tallest mountain in Africa
|
|
101
|
+
🌐 Driving Playwright to cross-domain target: https://html.duckduckgo.com/html/?q=tallest+mountain+in+Africa
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each turn takes from under a minute to a few minutes on a CPU (see [Performance](#performance)); the
|
|
105
|
+
[example run](#what-a-run-looks-like) below shows how it continues.
|
|
106
|
+
|
|
86
107
|
When it finishes, read `research_report.md` in the folder you ran it from. Press Ctrl+C at any time to
|
|
87
108
|
stop; the report is still written with everything found so far.
|
|
88
109
|
|
|
@@ -165,10 +186,13 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
|
|
|
165
186
|
| Documentation research | Setup commands and file layout for Playwright's test agents | 1.27 | Finished on its own in 13 turns (~36 min) with commands, roles and file layout |
|
|
166
187
|
| Documentation research, strict | The same, but "do not FINISH until fully validated", including "parameter schemas" | 1.38–1.40 | Its most detailed findings (per-client setup, seed test, agent definition files), but it keeps searching for schemas the docs do not contain and has not finished within an hour |
|
|
167
188
|
| Opinion from several sources | How spiritual traditions view cats, then form an opinion | 1.30 | Six traditions from two sources and a stated opinion, in 18 turns (37 min of model time) |
|
|
168
|
-
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40 | Correct, cited answer in 4 turns (about 12 min of model time) in
|
|
169
|
-
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 |
|
|
189
|
+
| Factual question | Tallest mountain in Africa, height in meters and feet | 1.35, 1.40, 1.46 | Correct, cited answer in 4 turns (about 12 min of model time) in all three versions; before 1.35 it kept reading after finding it |
|
|
190
|
+
| Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner. Later: SQLite vs PostgreSQL for a small web app, then a recommendation for a hobby project | 1.36, 1.42 | Python vs Rust: cited comparison recommending Python in 16 turns (33 min), Rust's libraries not covered. SQLite vs PostgreSQL: all three parts covered and cited, with a recommendation, in 13 turns (38 min) |
|
|
170
191
|
| List of items | Three free, open-source Photoshop alternatives and the strengths of each | 1.37–1.38 | GIMP, Krita and Darktable with their strengths, all cited, in 9 turns (24 min); 1.37 listed a fourth and added an unrequested opinion, which 1.38 fixes |
|
|
192
|
+
| List of items | Three free, open-source alternatives to Microsoft Office and what each is best at | 1.46 | LibreOffice and OnlyOffice with their strengths, cited, in 18 turns (49 min); the third, FreeOffice, is free but not open source, and LibreOffice was recorded four times in different words |
|
|
171
193
|
| Step-by-step how-to | Make a sourdough starter, with quantities and what to do each day | 1.36–1.37 | A clear plan in 7 turns (19 min), but it invented the first water amount; 1.37 stops guessing and flags unsupported numbers, and the agent then went looking for the missing details |
|
|
194
|
+
| Step-by-step how-to | Repot a houseplant: what you need, the steps, and care for the first two weeks | 1.43–1.44 | 1.43 recorded six steps it wrote itself before reading the part that had them; 1.44 checks findings against the page and recorded the guide's real materials, steps and aftercare, finishing in 24 turns (68 min), though the final result left out one step |
|
|
195
|
+
| Explanation of a topic | How photosynthesis works: inputs and outputs, both stages and where each takes place | 1.41–1.42 | A complete, cited explanation in 18 turns (45 min); 1.41 read four parts of a relevant page without recording them and had not finished after 49 min |
|
|
172
196
|
|
|
173
197
|
## How it works
|
|
174
198
|
|
|
@@ -201,8 +225,14 @@ flowchart LR
|
|
|
201
225
|
Duplicate findings are recognised even when reworded into different lines, punctuation or order.
|
|
202
226
|
- **Cheap retries.** A refused reply is corrected inside the same turn by appending a short note to the
|
|
203
227
|
conversation, instead of wasting a whole turn.
|
|
204
|
-
- **Confirm before leaving.** Leaving a page without recording anything from it triggers one
|
|
205
|
-
something, or repeat the action to leave.
|
|
228
|
+
- **Confirm before leaving or reading on.** Leaving a page without recording anything from it triggers one
|
|
229
|
+
check: record something, or repeat the action to leave. Moving on to the next part of a page works the same
|
|
230
|
+
way, and the model must first list what the part it is on says about the objective (or "none").
|
|
231
|
+
- **Findings must match the page.** A finding that shares too few word pairs with the page text shown so far
|
|
232
|
+
is sent back once, asking for only what the page says in its own words: a small model will otherwise
|
|
233
|
+
"complete" a list from its own knowledge when a page says "follow these 6 steps" and the steps come later.
|
|
234
|
+
- **Numbered findings.** Findings are numbered in the prompt and listed with every check, and the model names
|
|
235
|
+
the finding that covers each part of the objective ("covered by [2]") instead of just saying "covered".
|
|
206
236
|
- **Knowing when it's done.** After every new finding the model is asked whether the objective is now fully
|
|
207
237
|
covered, and if so to finish instead of reading on for extra details.
|
|
208
238
|
- **Progress and objective checks.** After 4 turns without a new finding, and every 8 turns regardless, the
|
|
@@ -285,11 +315,13 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
285
315
|
|
|
286
316
|
## Limitations
|
|
287
317
|
|
|
288
|
-
- **Tried on
|
|
289
|
-
research, an opinion from several sources, a factual question, a comparison, a how-to
|
|
318
|
+
- **Tried on seven kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
|
|
319
|
+
research, an opinion from several sources, a factual question, a comparison, a how-to, a list and an
|
|
320
|
+
explanation of a topic. Each one
|
|
290
321
|
exposed something that did not generalise and was fixed; other kinds of tasks may expose more.
|
|
291
|
-
- **Reading without recording.** The model
|
|
292
|
-
relevant parts
|
|
322
|
+
- **Reading without recording.** The model used to read long pages part by part without recording the
|
|
323
|
+
relevant parts. Since 1.42 it must say what a part contains before reading on, which fixed this on an
|
|
324
|
+
explanation task; it is still the area most likely to need more work on other pages.
|
|
293
325
|
- **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
|
|
294
326
|
secondary sources or stays with one source too long, and results vary with the first pages a search turns
|
|
295
327
|
up; every finding is in `research_report.md` regardless.
|
|
@@ -297,8 +329,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
297
329
|
"fully validated" can keep the agent looking for something the web may not contain. It is told to finish
|
|
298
330
|
and name what it could not find after looking in several places, but a 7B model does not always judge
|
|
299
331
|
when that point has come; the turn limit (100) and Ctrl+C still apply, and the report is always current.
|
|
300
|
-
- **Check the citations.** The final result only
|
|
301
|
-
wrong number on a citation,
|
|
332
|
+
- **Check the citations.** The final result is told to use only recorded findings, but a 7B model sometimes
|
|
333
|
+
puts the wrong number on a citation, adds a line no finding supports or leaves out a step, so verify a
|
|
334
|
+
claim against the listed findings before relying on it.
|
|
302
335
|
- **Some sites refuse automated browsers** (HTTP 403). The agent respects that and does not try to disguise
|
|
303
336
|
itself; it moves on to other sources.
|
|
304
337
|
- **Reading only.** The agent searches, opens pages and reads them. It does not click, fill in forms or sign in.
|
|
@@ -311,9 +344,9 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
|
|
|
311
344
|
- [x] Include every finding in the final report, not only the model's own summary (1.29)
|
|
312
345
|
- [x] Write the final result in a separate, rule-bound step and flag unsupported numbers (1.32–1.38)
|
|
313
346
|
- [x] Finish as soon as the objective is covered (1.35)
|
|
314
|
-
- [
|
|
347
|
+
- [x] Get the model to weigh each part of a long page before reading on (1.42)
|
|
315
348
|
- [ ] Let very strict objectives end with the gaps named instead of searching indefinitely
|
|
316
|
-
- [ ] Try more kinds of objectives (
|
|
349
|
+
- [ ] Try more kinds of objectives (seven so far) and adjust the rules that don't generalise
|
|
317
350
|
- [ ] Make fresh prompts (the slowest kind of turn) cheaper still (31% cheaper since 1.34)
|
|
318
351
|
- [ ] Interacting with pages (clicking, forms) for tasks beyond reading
|
|
319
352
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "localagentbrowser"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.47"
|
|
8
8
|
description = "An autonomous web agent driven by a local LLM (LM Studio) and Playwright. Runs entirely on your own machine."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -12,7 +12,8 @@ SEARCH_PAGE = "https://html.duckduckgo.com/html/?q=x"
|
|
|
12
12
|
|
|
13
13
|
def recorded_page(url, parts=("x",), index=0):
|
|
14
14
|
"""A content page the agent has already recorded something from (so no leave check applies)."""
|
|
15
|
-
return {"url": url, "parts": list(parts), "index": index, "seen_parts": {index}, "visit_discoveries": 1
|
|
15
|
+
return {"url": url, "parts": list(parts), "index": index, "seen_parts": {index}, "visit_discoveries": 1,
|
|
16
|
+
"recorded_parts": {index}}
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def unrecorded_page(url, parts=("p1", "p2", "p3")):
|
|
@@ -91,6 +92,12 @@ def test_read_more_target(parameter, expected_part):
|
|
|
91
92
|
assert ab.read_more_target(parameter, page) + 1 == expected_part
|
|
92
93
|
|
|
93
94
|
|
|
95
|
+
@pytest.mark.parametrize("parameter", [DOCS + "#comparison", "#comparison"])
|
|
96
|
+
def test_jumping_to_a_section_of_the_current_page_points_to_read_more(parameter):
|
|
97
|
+
result = decide("NAVIGATE", parameter, recorded_page(DOCS, parts=("a", "b")), DOCS)
|
|
98
|
+
assert result[0] == "BLOCKED_NAVIGATE" and "READ_MORE with that section's part number" in result[1]
|
|
99
|
+
|
|
100
|
+
|
|
94
101
|
def test_finish_check_asks_once_then_lets_finish_through():
|
|
95
102
|
tried = {"queries": [], "urls": []}
|
|
96
103
|
page = recorded_page(DOCS)
|
|
@@ -102,6 +109,15 @@ def test_finish_check_asks_once_then_lets_finish_through():
|
|
|
102
109
|
assert ab.check_decision("FINISH", "my answer", [], tried, page, DOCS) is None
|
|
103
110
|
|
|
104
111
|
|
|
112
|
+
def test_finish_check_lists_the_discoveries_numbered():
|
|
113
|
+
long_fact = "The Calvin cycle occurs in the stroma. " * 10
|
|
114
|
+
journal = discoveries("Light reactions happen in the thylakoids.", long_fact)
|
|
115
|
+
check = ab.check_decision("FINISH", "answer", journal, {"queries": [], "urls": []}, recorded_page(DOCS), DOCS)[1]
|
|
116
|
+
assert "[1] Light reactions happen in the thylakoids." in check and "[2] The Calvin cycle occurs" in check
|
|
117
|
+
assert long_fact.strip() not in check, "shortened in the check"
|
|
118
|
+
assert "'covered by [2]'" in check
|
|
119
|
+
|
|
120
|
+
|
|
105
121
|
def test_rereading_a_part_is_confirmed_once():
|
|
106
122
|
page = recorded_page(DOCS, parts=[f"p{i}" for i in range(11)], index=9)
|
|
107
123
|
page["seen_parts"] = {0, 1, 2, 3, 9}
|
|
@@ -111,6 +127,42 @@ def test_rereading_a_part_is_confirmed_once():
|
|
|
111
127
|
assert decide("READ_MORE", "11", page, DOCS) is None, "unread parts need no confirmation"
|
|
112
128
|
|
|
113
129
|
|
|
130
|
+
class TestReadOnCheck:
|
|
131
|
+
def test_reading_on_from_an_unrecorded_part_asks_once_then_proceeds(self):
|
|
132
|
+
page = unrecorded_page(DOCS)
|
|
133
|
+
first = decide("READ_MORE", "", page, DOCS)
|
|
134
|
+
assert first[0] == "CONFIRM_READ_ON" and "recorded nothing from part 1" in first[1]
|
|
135
|
+
assert "write 'none'" in first[1]
|
|
136
|
+
assert decide("READ_MORE", "", page, DOCS) is None, "repeating it goes through"
|
|
137
|
+
|
|
138
|
+
def test_no_check_after_recording_from_the_shown_part(self):
|
|
139
|
+
assert decide("READ_MORE", "", recorded_page(DOCS, parts=("a", "b")), DOCS) is None
|
|
140
|
+
|
|
141
|
+
def test_asked_again_for_the_next_unrecorded_part(self):
|
|
142
|
+
page = unrecorded_page(DOCS)
|
|
143
|
+
page["read_on_confirmed"] = {0}
|
|
144
|
+
page["index"] = 1
|
|
145
|
+
page["seen_parts"] = {0, 1}
|
|
146
|
+
assert decide("READ_MORE", "", page, DOCS)[0] == "CONFIRM_READ_ON"
|
|
147
|
+
|
|
148
|
+
def test_no_check_after_a_duplicate_from_the_shown_part(self):
|
|
149
|
+
page = unrecorded_page(DOCS)
|
|
150
|
+
journal = discoveries("already recorded fact")
|
|
151
|
+
assert decide("DISCOVER", "already recorded fact", page, DOCS, journal=journal)[0] == "DUPLICATE_DISCOVER"
|
|
152
|
+
assert decide("READ_MORE", "", page, DOCS, journal=journal) is None
|
|
153
|
+
|
|
154
|
+
def test_going_back_to_an_earlier_part_is_not_reading_on(self):
|
|
155
|
+
page = unrecorded_page(DOCS, parts=("a", "b", "c", "d", "e"))
|
|
156
|
+
page.update(index=4, seen_parts={0, 1, 2, 3, 4}, reread_confirmed={2})
|
|
157
|
+
assert decide("READ_MORE", "3", page, DOCS) is None
|
|
158
|
+
|
|
159
|
+
def test_asking_for_the_part_already_shown_is_refused(self):
|
|
160
|
+
result = decide("READ_MORE", "1", unrecorded_page(DOCS), DOCS)
|
|
161
|
+
assert result[0] == "BLOCKED_READ_MORE" and "already shown" in result[1] and "For part 2" in result[1]
|
|
162
|
+
last = recorded_page(DOCS, parts=("a", "b"), index=1)
|
|
163
|
+
assert "last part" in decide("READ_MORE", "2", last, DOCS)[1]
|
|
164
|
+
|
|
165
|
+
|
|
114
166
|
def test_outline_marks_parts_already_read():
|
|
115
167
|
page = {"parts": ["a"] * 6, "outline": [f"Section {i}" for i in range(6)], "index": 2, "seen_parts": {0, 2}}
|
|
116
168
|
outline = ab.render_outline(page)
|
|
@@ -186,3 +238,76 @@ class TestLeaveCheck:
|
|
|
186
238
|
page["seen_parts"] = {0, 1, 2}
|
|
187
239
|
message = decide("SEARCH", "q9", page, GUIDE)[1]
|
|
188
240
|
assert "unread" not in message and "repeat your SEARCH" in message
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestGroundingCheck:
|
|
244
|
+
PAGE = ("Repotting guide. Choose a pot one to two inches wider than the current one, with drainage holes. "
|
|
245
|
+
"Now it is time to repot. Just follow these six easy steps.")
|
|
246
|
+
|
|
247
|
+
def page(self):
|
|
248
|
+
page = unrecorded_page(GUIDE, parts=(self.PAGE, "Step 1: Water the plant the day before."))
|
|
249
|
+
return page
|
|
250
|
+
|
|
251
|
+
def test_a_discover_the_page_does_not_say_is_confirmed_once(self):
|
|
252
|
+
page = self.page()
|
|
253
|
+
invented = ("1. Gently remove the plant from its pot. 2. Loosen any tangled roots and trim dead ones. "
|
|
254
|
+
"3. Place fresh potting mix in the bottom of the new pot.")
|
|
255
|
+
first = decide("DISCOVER", invented, page, GUIDE)
|
|
256
|
+
assert first[0] == "CONFIRM_GROUNDED" and "does not match the page text" in first[1]
|
|
257
|
+
assert "DISCOVER again with only what the page actually says in part 1" in first[1]
|
|
258
|
+
assert decide("DISCOVER", invented, page, GUIDE) is None, "repeating it goes through"
|
|
259
|
+
|
|
260
|
+
def test_a_discover_copied_from_the_page_passes(self):
|
|
261
|
+
copied = "Choose a pot one to two inches wider than the current one, with drainage holes."
|
|
262
|
+
assert decide("DISCOVER", copied, self.page(), GUIDE) is None
|
|
263
|
+
|
|
264
|
+
def test_short_discoveries_are_not_checked(self):
|
|
265
|
+
assert decide("DISCOVER", "Use a bigger pot.", self.page(), GUIDE) is None
|
|
266
|
+
|
|
267
|
+
def test_real_advice_mixed_with_invented_steps_is_confirmed(self):
|
|
268
|
+
mixed = ("Choose a pot one to two inches wider than the current one, with drainage holes. The steps are: "
|
|
269
|
+
"loosen and inspect the roots, prune any dead roots, then fill with fresh potting mix.")
|
|
270
|
+
assert decide("DISCOVER", mixed, self.page(), GUIDE)[0] == "CONFIRM_GROUNDED"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def test_recorded_parts_are_remembered_across_visits(monkeypatch):
|
|
274
|
+
import asyncio
|
|
275
|
+
|
|
276
|
+
async def parts(page):
|
|
277
|
+
return ["part one", "part two"], [["One"], ["Two"]]
|
|
278
|
+
|
|
279
|
+
async def links(page, shown_links=None):
|
|
280
|
+
return ""
|
|
281
|
+
|
|
282
|
+
monkeypatch.setattr(ab, "extract_page_parts", parts)
|
|
283
|
+
monkeypatch.setattr(ab, "extract_page_links", links)
|
|
284
|
+
state = {"url": None, "parts": [], "index": 0}
|
|
285
|
+
asyncio.run(ab.build_environment(None, GUIDE, state))
|
|
286
|
+
state["recorded_parts"].add(0) # what the main loop does on DISCOVER
|
|
287
|
+
asyncio.run(ab.build_environment(None, DOCS, state))
|
|
288
|
+
assert decide("READ_MORE", "", state, DOCS)[0] == "CONFIRM_READ_ON"
|
|
289
|
+
asyncio.run(ab.build_environment(None, GUIDE, state))
|
|
290
|
+
assert decide("READ_MORE", "", state, GUIDE) is None, "part 1 was recorded on the earlier visit"
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_a_different_ungrounded_discover_is_checked_again():
|
|
294
|
+
page = TestGroundingCheck().page()
|
|
295
|
+
first = ("1. Gently remove the plant from its pot. 2. Loosen any tangled roots and trim dead ones before you "
|
|
296
|
+
"start. 3. Place fresh potting mix in the bottom of the new pot.")
|
|
297
|
+
second = ("During the first two weeks water your plant sparingly and avoid fertilizing it until it settles in. "
|
|
298
|
+
"Keep it out of direct sun and check the soil every few days.")
|
|
299
|
+
assert decide("DISCOVER", first, page, GUIDE)[0] == "CONFIRM_GROUNDED"
|
|
300
|
+
assert decide("DISCOVER", second, page, GUIDE)[0] == "CONFIRM_GROUNDED"
|
|
301
|
+
assert decide("DISCOVER", second, page, GUIDE) is None, "repeating the same one goes through"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def test_the_objectives_wording_counts_as_grounded():
|
|
305
|
+
page = unrecorded_page(DOCS, parts=("Kilimanjaro is a dormant volcano in Tanzania and the highest mountain in "
|
|
306
|
+
"Africa, at 5,895 m (19,341 ft) above sea level and 4,900 m above its base.",))
|
|
307
|
+
finding = ("The tallest mountain in Africa is Kilimanjaro, a dormant volcano in Tanzania. How tall is it in "
|
|
308
|
+
"meters and in feet: 5,895 m (19,341 ft) above sea level.")
|
|
309
|
+
tried = {"queries": [], "urls": []}
|
|
310
|
+
assert ab.check_decision("DISCOVER", finding, [], tried, page, DOCS)[0] == "CONFIRM_GROUNDED"
|
|
311
|
+
page["grounding_asked"] = []
|
|
312
|
+
tried["objective"] = "What is the tallest mountain in Africa, and how tall is it in meters and in feet?"
|
|
313
|
+
assert ab.check_decision("DISCOVER", finding, [], tried, page, DOCS) is None
|
|
@@ -51,3 +51,8 @@ def test_old_entries_are_dropped_in_batches():
|
|
|
51
51
|
shown = ab.compact_journal(journal).splitlines()
|
|
52
52
|
assert shown[0].startswith("T7 ") and len(shown) == 6
|
|
53
53
|
assert ab.compact_journal(journal + [{"turn": 13, "action": "READ_MORE", "part": "1 of 1"}]).splitlines()[0].startswith("T7 ")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_unverified_numbers_ignores_grouped_citations():
|
|
57
|
+
found = [{"extracted_data": "LibreOffice offers good compatibility."}]
|
|
58
|
+
assert ab.unverified_numbers("LibreOffice has good compatibility [4, 5] and more [2;3].", found) == []
|
|
@@ -45,7 +45,7 @@ class TestCheapRetries:
|
|
|
45
45
|
assert second[2]["role"] == "assistant" and second[3]["content"].startswith("REFUSED:") # ...then only appended to
|
|
46
46
|
|
|
47
47
|
def test_first_correction_lists_the_alternatives(self, scrapes):
|
|
48
|
-
client, model = fake_client([reply("NAVIGATE", URL), reply("
|
|
48
|
+
client, model = fake_client([reply("NAVIGATE", URL), reply("DISCOVER", "a new fact")])
|
|
49
49
|
turn(client)
|
|
50
50
|
correction = model.calls[1][-1]["content"]
|
|
51
51
|
assert "READ_MORE to see part 2 of 2" in correction and "NAVIGATE to another page" in correction
|
|
@@ -59,9 +59,9 @@ class TestCheapRetries:
|
|
|
59
59
|
assert "Choose a different action" in model.calls[-1][-1]["content"]
|
|
60
60
|
|
|
61
61
|
def test_invalid_json_gets_a_correction_not_the_identical_prompt(self, scrapes):
|
|
62
|
-
client, model = fake_client(["not json", reply("
|
|
62
|
+
client, model = fake_client(["not json", reply("DISCOVER", "a new fact")])
|
|
63
63
|
decision, _ = turn(client)
|
|
64
|
-
assert decision["action"] == "
|
|
64
|
+
assert decision["action"] == "DISCOVER" and "not a valid JSON object" in model.calls[1][-1]["content"]
|
|
65
65
|
|
|
66
66
|
def test_valid_first_reply_costs_one_call(self, scrapes):
|
|
67
67
|
client, model = fake_client([reply("SEARCH", "fresh query")])
|
|
@@ -71,7 +71,7 @@ class TestCheapRetries:
|
|
|
71
71
|
|
|
72
72
|
def test_fresh_prompt_shortens_long_discoveries(scrapes):
|
|
73
73
|
long_fact = "A long recorded finding. " * 40
|
|
74
|
-
client, model = fake_client([reply("
|
|
74
|
+
client, model = fake_client([reply("DISCOVER", "a new fact")])
|
|
75
75
|
journal = discoveries(long_fact)
|
|
76
76
|
turn(client, journal=journal)
|
|
77
77
|
prompt = model.calls[0][1]["content"]
|
|
@@ -92,6 +92,7 @@ class TestComposeFinalResult:
|
|
|
92
92
|
assert "Use only the information in the DISCOVERIES" in user["content"] and "'My view:'" in user["content"]
|
|
93
93
|
assert "unless the objective explicitly asks for your view" in user["content"] and "Do not fill gaps" in user["content"]
|
|
94
94
|
assert "give exactly that many" in user["content"]
|
|
95
|
+
assert "describes that situation, not the one in the OBJECTIVE" in user["content"]
|
|
95
96
|
assert "DRAFT" not in user["content"], "the agent's unverified draft is not shown"
|
|
96
97
|
|
|
97
98
|
def test_unwraps_a_json_reply(self):
|
|
@@ -149,3 +150,10 @@ class TestConversation:
|
|
|
149
150
|
ab.set_note(conversation, note, URL)
|
|
150
151
|
turn(client, conversation=conversation)
|
|
151
152
|
assert (len(model.calls[-1]) == 4) is continues
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_fresh_prompt_numbers_the_discoveries(scrapes):
|
|
156
|
+
client, model = fake_client([reply("DISCOVER", "a new fact")])
|
|
157
|
+
turn(client, journal=discoveries("first fact", "second fact"))
|
|
158
|
+
prompt = model.calls[0][1]["content"]
|
|
159
|
+
assert "### DISCOVERIES SO FAR\n[1] first fact\n[2] second fact\n" in prompt
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|