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.
Files changed (22) hide show
  1. {localagentbrowser-1.41 → localagentbrowser-1.47}/CHANGELOG.md +90 -0
  2. {localagentbrowser-1.41/localagentbrowser.egg-info → localagentbrowser-1.47}/PKG-INFO +48 -15
  3. {localagentbrowser-1.41 → localagentbrowser-1.47}/README.md +47 -14
  4. {localagentbrowser-1.41 → localagentbrowser-1.47}/agent_browser.py +120 -19
  5. {localagentbrowser-1.41 → localagentbrowser-1.47/localagentbrowser.egg-info}/PKG-INFO +48 -15
  6. {localagentbrowser-1.41 → localagentbrowser-1.47}/pyproject.toml +1 -1
  7. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_decisions.py +126 -1
  8. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_journal.py +5 -0
  9. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_model_turns.py +12 -4
  10. {localagentbrowser-1.41 → localagentbrowser-1.47}/LICENSE +0 -0
  11. {localagentbrowser-1.41 → localagentbrowser-1.47}/MANIFEST.in +0 -0
  12. {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/SOURCES.txt +0 -0
  13. {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/dependency_links.txt +0 -0
  14. {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/entry_points.txt +0 -0
  15. {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/requires.txt +0 -0
  16. {localagentbrowser-1.41 → localagentbrowser-1.47}/localagentbrowser.egg-info/top_level.txt +0 -0
  17. {localagentbrowser-1.41 → localagentbrowser-1.47}/setup.cfg +0 -0
  18. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/conftest.py +0 -0
  19. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/fakes.py +0 -0
  20. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_duplicates.py +0 -0
  21. {localagentbrowser-1.41 → localagentbrowser-1.47}/tests/test_main_loop.py +0 -0
  22. {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.41
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.** 89 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 six different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
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 both versions; before 1.35 it kept reading after finding it |
169
- | Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 | Cited comparison of performance, learning and Python's libraries, recommending Python, in 16 turns (33 min); Rust's libraries were not covered |
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 check: record
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 six kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
289
- research, an opinion from several sources, a factual question, a comparison, a how-to and a list. Each one
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 sometimes reads a long page part by part without recording the
292
- relevant parts, even with the outline in front of it. This is currently its main weakness.
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 uses recorded findings, but a 7B model sometimes puts the
301
- wrong number on a citation, so verify a claim against the listed findings before relying on it.
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
- - [ ] Get the model to record relevant parts while it reads long pages
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 (six so far) and adjust the rules that don't generalise
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.** 89 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 six different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
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 both versions; before 1.35 it kept reading after finding it |
137
- | Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 | Cited comparison of performance, learning and Python's libraries, recommending Python, in 16 turns (33 min); Rust's libraries were not covered |
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 check: record
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 six kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
257
- research, an opinion from several sources, a factual question, a comparison, a how-to and a list. Each one
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 sometimes reads a long page part by part without recording the
260
- relevant parts, even with the outline in front of it. This is currently its main weakness.
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 uses recorded findings, but a 7B model sometimes puts the
269
- wrong number on a citation, so verify a claim against the listed findings before relying on it.
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
- - [ ] Get the model to record relevant parts while it reads long pages
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 (six so far) and adjust the rules that don't generalise
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 that ask for your own answer, opinion, recommendation "
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
- footer = f"[Page continues: READ_MORE shows part {index + 2} of {len(parts)}]"
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
- if current_url and target == normalize_url(current_url):
493
- return "BLOCKED_NAVIGATE", (f"{where_you_are(page_state, current_url)} You are already on this page. "
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)} {FINISH_CHECK}"
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 = [shorten(e["extracted_data"], MAX_DISCOVERY_PROMPT_CHARS) for e in journal if e.get("action") == "DISCOVER"]
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
- {json.dumps(discoveries, indent=2) if discoveries else "[None yet]"}
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] in ("CONFIRM_LEAVE", "CONFIRM_FINISH", "CONFIRM_REREAD"):
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+\]", " ", str(text)) # Citation markers like [2]
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. If the objective asks for a specific number of items, give exactly that many (the best supported).\n"
848
- "3. Refer to the discoveries that support each point by their numbers, like [2], and only cite a number "
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
- "4. Do not add a 'My view:' paragraph unless the objective explicitly asks for your view, opinion or "
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
- "5. Write plain text, not JSON.")
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
- set_note(conversation, "RESULT: Your DISCOVER was recorded and is now part of DISCOVERIES SO FAR. "
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. {READ_FIRST} "
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.41
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.** 89 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 six different kinds of task (see [results](#results-on-different-kinds-of-tasks)).
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 both versions; before 1.35 it kept reading after finding it |
169
- | Comparison and recommendation | Python vs Rust for command-line tools; recommend one for a beginner | 1.36 | Cited comparison of performance, learning and Python's libraries, recommending Python, in 16 turns (33 min); Rust's libraries were not covered |
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 check: record
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 six kinds of task so far** (see [results](#results-on-different-kinds-of-tasks)): documentation
289
- research, an opinion from several sources, a factual question, a comparison, a how-to and a list. Each one
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 sometimes reads a long page part by part without recording the
292
- relevant parts, even with the outline in front of it. This is currently its main weakness.
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 uses recorded findings, but a 7B model sometimes puts the
301
- wrong number on a citation, so verify a claim against the listed findings before relying on it.
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
- - [ ] Get the model to record relevant parts while it reads long pages
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 (six so far) and adjust the rules that don't generalise
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.41"
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("READ_MORE", "")])
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("READ_MORE", "1")])
62
+ client, model = fake_client(["not json", reply("DISCOVER", "a new fact")])
63
63
  decision, _ = turn(client)
64
- assert decision["action"] == "READ_MORE" and "not a valid JSON object" in model.calls[1][-1]["content"]
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("READ_MORE", "")])
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