localagentbrowser 1.57__tar.gz → 1.61__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 (26) hide show
  1. {localagentbrowser-1.57 → localagentbrowser-1.61}/CHANGELOG.md +41 -2
  2. {localagentbrowser-1.57/localagentbrowser.egg-info → localagentbrowser-1.61}/PKG-INFO +28 -24
  3. {localagentbrowser-1.57 → localagentbrowser-1.61}/README.md +27 -23
  4. {localagentbrowser-1.57 → localagentbrowser-1.61}/agent_browser.py +170 -23
  5. {localagentbrowser-1.57 → localagentbrowser-1.61/localagentbrowser.egg-info}/PKG-INFO +28 -24
  6. {localagentbrowser-1.57 → localagentbrowser-1.61}/pyproject.toml +1 -1
  7. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_checklist.py +103 -8
  8. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_journal.py +7 -0
  9. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_main_loop.py +2 -2
  10. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_page_text.py +16 -2
  11. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_regression.py +6 -0
  12. {localagentbrowser-1.57 → localagentbrowser-1.61}/tools/regression.py +4 -1
  13. {localagentbrowser-1.57 → localagentbrowser-1.61}/LICENSE +0 -0
  14. {localagentbrowser-1.57 → localagentbrowser-1.61}/MANIFEST.in +0 -0
  15. {localagentbrowser-1.57 → localagentbrowser-1.61}/localagentbrowser.egg-info/SOURCES.txt +0 -0
  16. {localagentbrowser-1.57 → localagentbrowser-1.61}/localagentbrowser.egg-info/dependency_links.txt +0 -0
  17. {localagentbrowser-1.57 → localagentbrowser-1.61}/localagentbrowser.egg-info/entry_points.txt +0 -0
  18. {localagentbrowser-1.57 → localagentbrowser-1.61}/localagentbrowser.egg-info/requires.txt +0 -0
  19. {localagentbrowser-1.57 → localagentbrowser-1.61}/localagentbrowser.egg-info/top_level.txt +0 -0
  20. {localagentbrowser-1.57 → localagentbrowser-1.61}/setup.cfg +0 -0
  21. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/conftest.py +0 -0
  22. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/fakes.py +0 -0
  23. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_decisions.py +0 -0
  24. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_duplicates.py +0 -0
  25. {localagentbrowser-1.57 → localagentbrowser-1.61}/tests/test_model_turns.py +0 -0
  26. {localagentbrowser-1.57 → localagentbrowser-1.61}/tools/live_test.py +0 -0
@@ -1,11 +1,50 @@
1
1
  # Changelog
2
2
 
3
- Every version of `agent_browser.py`, newest first. Each released version is also a tagged commit (`v1.11` … `v1.57`); 1.54 to 1.56 were internal steps
4
- measured on the regression suite and released as part of 1.57 (see
3
+ Every version of `agent_browser.py`, newest first. Each released version is also a tagged commit (`v1.11` … `v1.61`); 1.54 to 1.56 and 1.58 to 1.60 were internal steps
4
+ measured on the regression suite and released as part of 1.57 and 1.61 (see
5
5
  [docs/regression-history.md](docs/regression-history.md)).
6
6
  Timings are from live runs on the development machine (Intel i5-9500, 6 cores, no GPU, Qwen2.5-7B-Instruct
7
7
  Q4_K_M in LM Studio), where reading the prompt takes about 90% of every model call.
8
8
 
9
+ ## [1.61] - 2026-10-21
10
+ Released together with the internal steps 1.58, 1.59 and 1.60, each measured on the regression suite
11
+ ([docs/regression-history.md](docs/regression-history.md) has every step's results, task by task).
12
+ ### Added
13
+ - An outline pointer for long pages. On a page long enough to have an outline, the read-on check looks for an unread
14
+ part whose label names a word of an item with nothing recorded yet ("Administration" for "setup and
15
+ administration"), and a plain READ_MORE then reads that part instead of the next one. Told only to use the
16
+ outline, the model went on to the next part 6 times out of 6 on a 10-part comparison page; with the code making
17
+ the jump, it reaches the part every time. Words that say what the whole page is about are ignored (the page title's
18
+ words, words every item shares), as are item verbs and parts that start inside code; hyphenated words stay whole.
19
+ On 1.61 the comparison finished for the first time since 1.56, from that page's Administration part.
20
+ - "Every item has the discoveries it asks for: choose FINISH now" in the note after a discovery, when each item has
21
+ at least what it asks for. When that holds, the read-on check also asks for FINISH instead of offering to read on
22
+ (replayed: read on 6 of 6 before, FINISH 9 of 9 after).
23
+ ### Changed
24
+ - The agent is sent back before finishing only for gaps the code verifies: an item nothing was copied for, fewer
25
+ things than the item asks for, or nothing recorded. The model's other verdicts are logged "(not sent back)" and
26
+ only shape the final result. About half of the send-backs for those verdicts had been wrong, costing finishes.
27
+ - Every item that asks for a number is counted, not only items with a condition: an owl task finished with two
28
+ tribes for "at least three cultures". Amounts of time or measure ("the first two weeks") are not counts.
29
+ - A thing not shown to meet a condition stays in the result, marked where it is first named ("**OnlyOffice** (not
30
+ shown in the pages read to be free and open-source)"), instead of being left out: leaving such things out dropped
31
+ correct answers along with wrong ones.
32
+ - A description named by the coverage check as one of a list's things is ignored (it had made two office suites
33
+ count as three).
34
+ - Smaller fresh prompts: refusal reasons in the journal are cut to 120 characters, pages list 8 links instead of 15
35
+ (those with a few words of text first; in four suites the agent opened 39 pages, none from page links), and links
36
+ whose text is HTML are skipped. Fresh prompts are about 2,500 tokens instead of 3,500-4,900.
37
+ - Regression suite: the scorer accepts a forbidden item when the result marks it with the caveat.
38
+ ### Fixed
39
+ - When several things shared a caveat, only the first was marked: the identical note next to it counted for all.
40
+
41
+ Regression runs, finished out of 7 (model minutes): 1.58 5 (214), 1.59 5 (209.5), 1.60 6 (170.6), 1.61 6 (222.1).
42
+ What is still open: a fresh prompt (when a conversation reaches its token budget) does not carry the advice to
43
+ finish, and on 1.61 the documentation task read on after one with all four items found; nothing checks that a
44
+ recipe's steps reach their end (on 1.60 cold brew stopped after step 2). Measured and not used: checking a
45
+ discovery's link to its item by shared words (most links without a shared word are right), and a 16,384-token
46
+ context, which would halve the fresh prompts but needs more memory than the default aims for.
47
+
9
48
  ## [1.57] - 2026-10-19
10
49
  Released together with the internal steps 1.54, 1.55 and 1.56, each of which was measured on the regression suite
11
50
  but not released on its own. [docs/regression-history.md](docs/regression-history.md) has every step's results,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localagentbrowser
3
- Version: 1.57
3
+ Version: 1.61
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
@@ -199,19 +199,19 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
199
199
 
200
200
  Since 1.53, every change is measured on the same seven objectives, one of each kind above, run one after the other
201
201
  with a time limit each (`tools/regression.py`, see [Testing](#testing)). Single runs vary with the pages a search
202
- turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 were internal steps measured this way
203
- and released together as 1.57; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
202
+ turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 and 1.58 to 1.60 were internal steps
203
+ measured this way and released with 1.57 and 1.61; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
204
204
  has every step's results and what each one showed.
205
205
 
206
- | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) |
207
- |---|---|---|---|
208
- | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min |
209
- | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" |
210
- | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for |
211
- | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time |
212
- | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited |
213
- | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for |
214
- | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one |
206
+ | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) |
207
+ |---|---|---|---|---|
208
+ | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min |
209
+ | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" | Finished, 21 min: LibreOffice, WPS Office and OnlyOffice, each marked "not shown in the pages read to be free and open-source" |
210
+ | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for | Finished, 26 min, setup and administration from the part of a long page the outline pointer chose |
211
+ | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time | Finished, 33 min: every step, the 16-hour steep and the shelf life, cited |
212
+ | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited | Finished, 39 min |
213
+ | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for | Finished, 42 min: four cultures, cited, with a view |
214
+ | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one | Time limit: all four items found from python.org, then it read on |
215
215
 
216
216
  ## How it works
217
217
 
@@ -323,9 +323,9 @@ Measured on an Intel i5-9500 (6 cores, no GPU) with Qwen2.5-7B-Instruct Q4_K_M a
323
323
  | Coverage check before finishing (all checklist items) | about 1–3 min |
324
324
  | Writing the final result (once, at the end) | 50–190 s, depending on how much was found |
325
325
 
326
- Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 13 to
327
- 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 21 to 44 when they finished;
328
- and the explanation 13 to 59. A GPU or a smaller model will be much faster.
326
+ Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 9 to
327
+ 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 17 to 44 when they finished;
328
+ and the explanation 13 to 59. A whole suite of seven tasks took 171 to 272 minutes. A GPU or a smaller model will be much faster.
329
329
 
330
330
  ## FAQ
331
331
 
@@ -363,15 +363,17 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
363
363
  - **Judging whether a finding covers an item is the weakest area.** The model's verdict is unreliable both ways:
364
364
  on 1.53 the explanation finished from one overview paragraph without oxygen or water, and in the regression runs
365
365
  since 1.54 about half of the coverage check's send-backs were for gaps that were not real ("missing official
366
- documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). The code now verifies what
367
- it can (an item nothing answers, a count, word-for-word proof of a condition), but each wrong send-back still
368
- costs a finish on this hardware, since a send-back costs 8–10 minutes.
366
+ documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). Since 1.58 the agent is sent
367
+ back only for what the code verifies (an item nothing answers, fewer things than asked for); the model's other
368
+ verdicts only shape the final result, so a thin answer can still pass (on 1.61 the comparison's "many
369
+ simultaneous users" part is short).
369
370
  - **Conditions in the objective.** A small model tends to drop conditions such as "open source" or "using the
370
371
  official documentation": a list of open-source office suites once included FreeOffice (free but not open
371
372
  source), and a task that asked for the official Python documentation used a tutorial site. The objective
372
373
  checklist keeps the conditions in view (the documentation task uses python.org since 1.55), and a thing not
373
- shown in the findings to meet a condition is left out of the result and listed after it. The check is strict:
374
- on 1.57 it sent back three correct open-source suites because no words showed them to be "free". Check the
374
+ shown in the findings to meet a condition is marked where it is named ("not shown in the pages read to be free
375
+ and open-source"). The mark says what the pages read did not show, not that the thing fails the condition: on
376
+ 1.61 it marked LibreOffice and OnlyOffice, which are open source, as well as WPS Office, which is not. Check the
375
377
  result against any conditions you set.
376
378
  - **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
377
379
  secondary sources or stays with one source too long, and results vary with the first pages a search turns
@@ -402,8 +404,10 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
402
404
  - [x] Keep a fixed checklist of the objective's parts instead of re-splitting it at every check (1.53)
403
405
  - [ ] Check that a finding really covers its checklist item, including conditions like "open source" (partly, since
404
406
  1.57: a coverage check before finishing, with what the code can verify)
405
- - [ ] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
406
- result (the next step after 1.57)
407
+ - [x] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
408
+ result (1.58, released in 1.61)
409
+ - [x] Point the agent to the part of a long page whose outline names an item it still needs (1.61)
410
+ - [ ] Keep the advice to finish when a conversation starts over from a fresh prompt (the next step after 1.61)
407
411
  - [ ] Rewind dead ends: save the conversation before an expensive step such as reading a long page, and if it
408
412
  leads nowhere, return to that point with a short note instead of carrying the unused page text in every later
409
413
  prompt (LM Studio reuses its cache up to the saved point)
@@ -456,8 +460,8 @@ the [Code of Conduct](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CO
456
460
  ## Project history
457
461
 
458
462
  Every released version is a tagged commit, and [CHANGELOG.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CHANGELOG.md) describes what changed in each one and
459
- why, with the measurements behind the decisions. Versions 1.54 to 1.56 were internal steps, each measured on the
460
- regression suite and released together as 1.57; their results are in
463
+ why, with the measurements behind the decisions. Versions 1.54 to 1.56 and 1.58 to 1.60 were internal steps, each
464
+ measured on the regression suite and released with 1.57 and 1.61; their results are in
461
465
  [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md).
462
466
 
463
467
  ## Acknowledgements
@@ -167,19 +167,19 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
167
167
 
168
168
  Since 1.53, every change is measured on the same seven objectives, one of each kind above, run one after the other
169
169
  with a time limit each (`tools/regression.py`, see [Testing](#testing)). Single runs vary with the pages a search
170
- turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 were internal steps measured this way
171
- and released together as 1.57; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
170
+ turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 and 1.58 to 1.60 were internal steps
171
+ measured this way and released with 1.57 and 1.61; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
172
172
  has every step's results and what each one showed.
173
173
 
174
- | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) |
175
- |---|---|---|---|
176
- | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min |
177
- | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" |
178
- | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for |
179
- | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time |
180
- | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited |
181
- | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for |
182
- | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one |
174
+ | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) |
175
+ |---|---|---|---|---|
176
+ | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min |
177
+ | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" | Finished, 21 min: LibreOffice, WPS Office and OnlyOffice, each marked "not shown in the pages read to be free and open-source" |
178
+ | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for | Finished, 26 min, setup and administration from the part of a long page the outline pointer chose |
179
+ | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time | Finished, 33 min: every step, the 16-hour steep and the shelf life, cited |
180
+ | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited | Finished, 39 min |
181
+ | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for | Finished, 42 min: four cultures, cited, with a view |
182
+ | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one | Time limit: all four items found from python.org, then it read on |
183
183
 
184
184
  ## How it works
185
185
 
@@ -291,9 +291,9 @@ Measured on an Intel i5-9500 (6 cores, no GPU) with Qwen2.5-7B-Instruct Q4_K_M a
291
291
  | Coverage check before finishing (all checklist items) | about 1–3 min |
292
292
  | Writing the final result (once, at the end) | 50–190 s, depending on how much was found |
293
293
 
294
- Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 13 to
295
- 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 21 to 44 when they finished;
296
- and the explanation 13 to 59. A GPU or a smaller model will be much faster.
294
+ Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 9 to
295
+ 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 17 to 44 when they finished;
296
+ and the explanation 13 to 59. A whole suite of seven tasks took 171 to 272 minutes. A GPU or a smaller model will be much faster.
297
297
 
298
298
  ## FAQ
299
299
 
@@ -331,15 +331,17 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
331
331
  - **Judging whether a finding covers an item is the weakest area.** The model's verdict is unreliable both ways:
332
332
  on 1.53 the explanation finished from one overview paragraph without oxygen or water, and in the regression runs
333
333
  since 1.54 about half of the coverage check's send-backs were for gaps that were not real ("missing official
334
- documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). The code now verifies what
335
- it can (an item nothing answers, a count, word-for-word proof of a condition), but each wrong send-back still
336
- costs a finish on this hardware, since a send-back costs 8–10 minutes.
334
+ documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). Since 1.58 the agent is sent
335
+ back only for what the code verifies (an item nothing answers, fewer things than asked for); the model's other
336
+ verdicts only shape the final result, so a thin answer can still pass (on 1.61 the comparison's "many
337
+ simultaneous users" part is short).
337
338
  - **Conditions in the objective.** A small model tends to drop conditions such as "open source" or "using the
338
339
  official documentation": a list of open-source office suites once included FreeOffice (free but not open
339
340
  source), and a task that asked for the official Python documentation used a tutorial site. The objective
340
341
  checklist keeps the conditions in view (the documentation task uses python.org since 1.55), and a thing not
341
- shown in the findings to meet a condition is left out of the result and listed after it. The check is strict:
342
- on 1.57 it sent back three correct open-source suites because no words showed them to be "free". Check the
342
+ shown in the findings to meet a condition is marked where it is named ("not shown in the pages read to be free
343
+ and open-source"). The mark says what the pages read did not show, not that the thing fails the condition: on
344
+ 1.61 it marked LibreOffice and OnlyOffice, which are open source, as well as WPS Office, which is not. Check the
343
345
  result against any conditions you set.
344
346
  - **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
345
347
  secondary sources or stays with one source too long, and results vary with the first pages a search turns
@@ -370,8 +372,10 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
370
372
  - [x] Keep a fixed checklist of the objective's parts instead of re-splitting it at every check (1.53)
371
373
  - [ ] Check that a finding really covers its checklist item, including conditions like "open source" (partly, since
372
374
  1.57: a coverage check before finishing, with what the code can verify)
373
- - [ ] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
374
- result (the next step after 1.57)
375
+ - [x] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
376
+ result (1.58, released in 1.61)
377
+ - [x] Point the agent to the part of a long page whose outline names an item it still needs (1.61)
378
+ - [ ] Keep the advice to finish when a conversation starts over from a fresh prompt (the next step after 1.61)
375
379
  - [ ] Rewind dead ends: save the conversation before an expensive step such as reading a long page, and if it
376
380
  leads nowhere, return to that point with a short note instead of carrying the unused page text in every later
377
381
  prompt (LM Studio reuses its cache up to the saved point)
@@ -424,8 +428,8 @@ the [Code of Conduct](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CO
424
428
  ## Project history
425
429
 
426
430
  Every released version is a tagged commit, and [CHANGELOG.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CHANGELOG.md) describes what changed in each one and
427
- why, with the measurements behind the decisions. Versions 1.54 to 1.56 were internal steps, each measured on the
428
- regression suite and released together as 1.57; their results are in
431
+ why, with the measurements behind the decisions. Versions 1.54 to 1.56 and 1.58 to 1.60 were internal steps, each
432
+ measured on the regression suite and released with 1.57 and 1.61; their results are in
429
433
  [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md).
430
434
 
431
435
  ## Acknowledgements
@@ -30,11 +30,13 @@ SEARCH_URL = "https://html.duckduckgo.com/html/?q="
30
30
  REPORT_FILE = "research_report.md"
31
31
  JOURNAL_BATCH = 6 # The prompt shows 6-11 recent journal entries; old ones are dropped 6 at a time
32
32
  MAX_JOURNAL_DETAIL_CHARS = 120 # Longest query/URL/request text shown per journal line
33
+ MAX_JOURNAL_REASON_CHARS = 120 # Longest refusal reason shown per journal line (the full text is in research_log.txt)
33
34
  MAX_THOUGHT_CHARS = 120 # Old reasoning is trimmed in the prompt; the full text stays in research_log.txt
34
35
  MAX_SEARCH_RESULTS = 8
35
36
  MAX_SNIPPET_CHARS = 200
36
37
  PAGE_PART_CHARS = 2500 # Pages are shown in parts of about this size; READ_MORE shows the next part
37
- MAX_LINKS = 15
38
+ MAX_LINKS = 8 # Links listed per page; most pages are reached from search results
39
+ CONTENT_LINK_WORDS = 3 # Links with at least this many words of text are listed before shorter (menu) links
38
40
  OUTLINE_MIN_PARTS = 5 # Pages with at least this many parts get an outline of what each part covers
39
41
  MAX_OUTLINE_LINES = 40
40
42
  MAX_OUTLINE_LABEL = 90
@@ -75,6 +77,8 @@ MAX_CHECKLIST_ITEM_CHARS = 200
75
77
  SPLIT_MAX_TOKENS = 300
76
78
  COVERAGE_MAX_TOKENS = 400 # Length limit for the check of one checklist item before finishing
77
79
  MAX_CONDITION_WORDS = 2 # Longer "conditions" are descriptions of the item, not properties a thing must have
80
+ MAX_THING_NAME_WORDS = 6 # Longer "things" named by the coverage check are descriptions, not names
81
+ UNANSWERED_GAP = "nothing in DISCOVERIES SO FAR answers it"
78
82
  MAX_MISSING_CLAIM_WORDS = 4 # Short "missing" claims are checked against the discoveries' words; longer ones are kept
79
83
  COVERAGE_ITEM_CHECK = (
80
84
  "Check the one CHECKLIST item named at the end against the DISCOVERIES. Give:\n"
@@ -86,9 +90,10 @@ COVERAGE_ITEM_CHECK = (
86
90
  "Do not repeat what the evidence already says; '' if the evidence fully answers it;\n"
87
91
  "- conditions: the conditions the item sets on the things it asks for, in its own words (for example \"free\", "
88
92
  "\"open-source\", \"official\"); [] if it sets none;\n"
89
- "- things: if the item has conditions, one entry for each thing the evidence names for it, with \"proof\": the exact "
90
- "words from the DISCOVERIES that show that thing meets the conditions ('' if the DISCOVERIES do not say it); [] if "
91
- "the item has no conditions.")
93
+ "- things: if the item asks for a number of things or has conditions, one entry for each different thing the "
94
+ "evidence names for it, with \"proof\": the exact words from the DISCOVERIES that show that thing meets the "
95
+ "conditions, or that name it if there are no conditions ('' if the DISCOVERIES do not say it); [] if the item asks "
96
+ "for neither.")
92
97
  COVERAGE_FORMAT = {"type": "json_schema", "json_schema": {"name": "coverage", "strict": True, "schema": {
93
98
  "type": "object", "properties": {
94
99
  "evidence": {"type": "string"}, "missing": {"type": "string"},
@@ -364,10 +369,19 @@ async def extract_page_links(page, shown_links=None):
364
369
  seen = set()
365
370
  link_lines = []
366
371
  omitted = 0
372
+ # Links with a few words of text (article titles, "Next: ...") before short ones (menu items such as "GUIDES"),
373
+ # each group in page order: the list is capped, and menus come first on most pages. In 4 regression suites the
374
+ # agent opened 39 pages, all from search results and none from page links, while the links were more than half
375
+ # of every new page it was shown
376
+ links = sorted(links, key=lambda link: len(link["text"].split()) < CONTENT_LINK_WORDS)
367
377
  for link in links:
368
378
  if link["href"] in seen or normalize_url(link["href"]) == normalize_url(page.url):
369
379
  continue
370
380
  seen.add(link["href"])
381
+ # Image links whose text is the image's HTML ('<img src="...') say nothing and took about a third of a
382
+ # site's link list (shop links, mostly)
383
+ if link["text"].startswith("<"):
384
+ continue
371
385
  if shown_links is not None and link["href"] in shown_links:
372
386
  omitted += 1
373
387
  continue
@@ -559,6 +573,11 @@ def read_more_target(parameter, page_state):
559
573
  # The model sometimes writes "2 of 2" or "part 3"; the first number is the part it means
560
574
  number = re.search(r'\d+', parameter)
561
575
  target = int(number.group()) - 1 if number else page_state["index"] + 1
576
+ # Told by the read-on check to read the outline part that names an open item, the model repeated a plain
577
+ # READ_MORE for the next part in 2 to 3 of 6 replays; a plain READ_MORE after that advice reads the named part
578
+ pointed = page_state.get("pointed")
579
+ if not number and pointed and pointed[0] == (page_state.get("url"), page_state["index"]):
580
+ target = pointed[1]
562
581
  # Asking for the part already shown nearly always meant the next one ("READ_MORE 9" while on part 9 came up
563
582
  # in most long-page runs, and refusing it cost a retry each time), so it is read as that, except on the last part
564
583
  if target == page_state["index"] and target < len(page_state["parts"]) - 1:
@@ -611,15 +630,81 @@ def read_on_check(page_state, current_url, target, checklist=None):
611
630
  confirmed.add(index)
612
631
  # Naming the items still open: on an explanation task the model answered 'none' for every part of an overview
613
632
  # that gave the inputs and outputs it still needed, while it was thinking about the Calvin cycle
633
+ # With every item holding what it asks for, a documentation task got "choose FINISH now" after a discovery and
634
+ # read four more parts anyway, answering each of these checks 'none' and repeating READ_MORE: then this check
635
+ # asks for FINISH first and no longer offers reading on
636
+ if every_item_has_discoveries(checklist):
637
+ return "CONFIRM_READ_ON", (f"Before reading on: every item of the OBJECTIVE CHECKLIST already has the discoveries "
638
+ f"it asks for. Choose FINISH now with the complete final result. Only if part "
639
+ f"{index + 1}, which is shown now, gives something an item still clearly lacks, "
640
+ "DISCOVER that instead.")
614
641
  still_open = open_items(checklist)
615
642
  serves = f"the OBJECTIVE (above all the checklist items with nothing recorded yet: {still_open})" if still_open else "the OBJECTIVE"
643
+ pointer = outline_pointer(page_state, checklist)
644
+ if pointer and pointer[0] != index + 2:
645
+ page_state["pointed"] = ((page_state.get("url"), index), pointer[0] - 1)
646
+ read_on = (f"repeat your READ_MORE: it will show part {pointer[0]}, not the next part, because the Page Outline "
647
+ f"names '{pointer[1]}' there, from an item with nothing recorded yet" if pointer and pointer[0] != index + 2
648
+ else "repeat your READ_MORE")
616
649
  return "CONFIRM_READ_ON", (f"Before reading on: you have recorded nothing from part {index + 1}, which is shown now. "
617
650
  f"In thought_process, list each fact in it that serves {serves}, or write 'none'. If any "
618
- "of them are not in DISCOVERIES SO FAR, DISCOVER them now. If there are none, repeat your "
619
- "READ_MORE. But if DISCOVERIES SO FAR already cover every item of the OBJECTIVE CHECKLIST, choose "
651
+ f"of them are not in DISCOVERIES SO FAR, DISCOVER them now. If there are none, {read_on}. "
652
+ "But if DISCOVERIES SO FAR already cover every item of the OBJECTIVE CHECKLIST, choose "
620
653
  "FINISH instead of reading on, and if this page is not about the OBJECTIVE at all, leave "
621
654
  "it with SEARCH or NAVIGATE.")
622
655
 
656
+ # Words that say what to do with an item, not what it is about ("Compare ... features" matched "How do they compare")
657
+ ITEM_VERBS = {"compare", "find", "explain", "describe", "detail", "list", "give", "name", "say", "what", "which", "each",
658
+ "about", "into", "does", "make"}
659
+
660
+ def outline_words(text):
661
+ # Hyphenated words stay whole: "open-source" split into "open" matched "Can I open and edit ..." in an FAQ
662
+ return re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)*", str(text).lower())
663
+
664
+ def outline_word_parts(outline):
665
+ """For each word in the outline labels, the part numbers (1-based) whose label has it."""
666
+ parts = {}
667
+ for number, label in enumerate(outline, 1):
668
+ for word in set(outline_words(label)):
669
+ parts.setdefault(word.rstrip("s"), set()).add(number)
670
+ return parts
671
+
672
+ def outline_pointer(page_state, checklist):
673
+ """The first unread part whose outline label names a word of an open checklist item, as (part, word), or None.
674
+
675
+ Told to use the outline to reach an open item, the model still read the next part 6 of 6 times on a 10-part
676
+ comparison page whose outline named "Administration" and "Features" in parts 5 and 6. Words in many labels
677
+ ("SQLite", "PostgreSQL" in every heading of that page) say nothing about where an item is, so only words in
678
+ at most a third of the labels count.
679
+ """
680
+ outline = page_state.get("outline") or []
681
+ if len(outline) < OUTLINE_MIN_PARTS:
682
+ return None
683
+ seen = page_state.get("seen_parts") or set()
684
+ parts = page_state.get("parts") or []
685
+ where = outline_word_parts(outline)
686
+ # Topic words point anywhere: the page's own title (its first label: "photosynthesis" on the Photosynthesis
687
+ # article, "free" on a page of free office suites) and words every item shares ("SQLite" in each item of a
688
+ # comparison) say what the whole page is about, not where one item is
689
+ to_find = [item for item in checklist or [] if not OPINION_REQUEST.search(item["text"])]
690
+ topic = {w.rstrip("s") for w in outline_words(outline[0])}
691
+ if len(to_find) > 1:
692
+ topic |= set.intersection(*({w.rstrip("s") for w in outline_words(item["text"])} for item in to_find))
693
+ for item in to_find:
694
+ if item["discoveries"]:
695
+ continue
696
+ for word in outline_words(item["text"]):
697
+ key = word.rstrip("s")
698
+ if len(key) < 4 or word in MISSING_STOPWORDS or word in ITEM_VERBS or key in topic:
699
+ continue
700
+ # A part that starts inside code is labelled by its first code line, not a heading: on the venv docs,
701
+ # "Install pip in the virtual environment." was a docstring in the module's source
702
+ numbers = sorted(n for n in where.get(key, ()) if n - 1 not in seen
703
+ and not str(parts[n - 1] if n <= len(parts) else "").lstrip().startswith("```"))
704
+ if numbers and len(where[key]) <= len(outline) / 3:
705
+ return numbers[0], word
706
+ return None
707
+
623
708
  def gap_check(thought, tried):
624
709
  # The model is asked to write 'not covered' for missing parts; on a comparison it wrote "Available Libraries:
625
710
  # Not covered" and finished anyway. A FINISH whose own reasoning says that is sent back to work on the gap,
@@ -776,6 +861,29 @@ def checklist_lines(checklist):
776
861
  lines.append(f"{number}. {item['text']} (discoveries for it: {found or 'none yet'})")
777
862
  return "\n".join(lines)
778
863
 
864
+ def every_item_has_discoveries(checklist):
865
+ """Whether each item to find has at least the discoveries it asks for: one, or the number it names."""
866
+ items = [item for item in checklist or [] if not OPINION_REQUEST.search(item["text"])]
867
+ needed = [asked_count(item["text"]) or 1 for item in items]
868
+ # As many different discoveries as the items ask for in all: on 1.53 one overview paragraph was linked to all four
869
+ # items of an explanation, and finishing from it gave a thin result
870
+ distinct = {n for item in items for n in item["discoveries"]}
871
+ return (bool(items) and all(len(item["discoveries"]) >= k for item, k in zip(items, needed, strict=True))
872
+ and len(distinct) >= sum(needed))
873
+
874
+ def after_discovery_advice(checklist):
875
+ # With Western, European and Native American views recorded for "at least three cultures", an owl task kept
876
+ # searching for more until its time ran out (twice); told only "if DISCOVERIES SO FAR now cover every item", the
877
+ # model kept judging that they did not. When the code can see each item has what it asks for, it says so; the
878
+ # coverage check before FINISH still looks at what the discoveries say
879
+ if every_item_has_discoveries(checklist):
880
+ return ("Every item of the OBJECTIVE CHECKLIST now has the discoveries it asks for (an item asking for three "
881
+ "things has three). Choose FINISH now with the complete final result, unless one of these discoveries "
882
+ "clearly does not answer its item; do not read further for extra or merely interesting details.")
883
+ return ("If DISCOVERIES SO FAR now cover every item of the OBJECTIVE CHECKLIST, choose FINISH now; do not read further "
884
+ "for extra or merely interesting details. If some items are still not covered, work on those next instead of "
885
+ "adding more detail to items already covered.")
886
+
779
887
  def open_items(checklist):
780
888
  """The checklist items nothing has been recorded for yet (opinions are written at FINISH), as one line for prompts."""
781
889
  return "; ".join(f"{n}. {item['text']}" for n, item in enumerate(checklist or [], 1)
@@ -806,10 +914,17 @@ def check_parts(tried):
806
914
  f"OBJECTIVE CHECKLIST:\n{checklist_lines(checklist)}\n")
807
915
 
808
916
  NUMBER_WORDS = {"two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10}
917
+ MEASURE_WORDS = {"second", "seconds", "minute", "minutes", "hour", "hours", "day", "days", "week", "weeks", "month",
918
+ "months", "year", "years", "cup", "cups", "times", "percent"}
809
919
 
810
920
  def asked_count(text):
811
- """The number of things an item asks for ("three ...", "5 ..."), or None. "At least three" counts as three."""
812
- for word in plain_words(text):
921
+ """The number of things an item asks for ("three ...", "5 ..."), or None. "At least three" counts as three.
922
+
923
+ A number of time units or amounts ("the first two weeks", "2 cups") is not a number of things to find."""
924
+ words = plain_words(text)
925
+ for word, after in zip(words, words[1:] + [""], strict=True):
926
+ if after in MEASURE_WORDS:
927
+ continue
813
928
  if word in NUMBER_WORDS:
814
929
  return NUMBER_WORDS[word]
815
930
  if word.isdigit() and 2 <= int(word) <= 20:
@@ -892,7 +1007,10 @@ async def coverage_gaps(client, objective, checklist, journal, only=None):
892
1007
  conditions = [c for c in verdict.get("conditions") or [] if isinstance(c, str) and states(item["text"], c)
893
1008
  and len([w for w in plain_words(c) if len(w) > 2]) <= MAX_CONDITION_WORDS
894
1009
  and asked_count(item["text"])]
895
- things = [t for t in verdict.get("things") or [] if isinstance(t, dict) and str(t.get("name", "")).strip()]
1010
+ # A thing is a name ("OnlyOffice"), not a sentence: the model also listed ".docx/.xlsx/.pptx compatibility
1011
+ # is genuinely excellent ..." as one, which got a caveat of its own and made two suites count as three
1012
+ things = [t for t in verdict.get("things") or [] if isinstance(t, dict) and str(t.get("name", "")).strip()
1013
+ and "\n" not in str(t["name"]).strip() and len(str(t["name"]).split()) <= MAX_THING_NAME_WORDS]
896
1014
  except Exception as err:
897
1015
  print(f"⚠️ Coverage check of item {n} failed ({err}); treating it as covered.")
898
1016
  continue
@@ -901,15 +1019,16 @@ async def coverage_gaps(client, objective, checklist, journal, only=None):
901
1019
  # that item to "the best time to repot is spring and summer", and the final result invented a care routine
902
1020
  if not re.search(r"\w", str(verdict.get("evidence", ""))):
903
1021
  unanswered.append(n)
904
- gaps.append(f"Item {n} ({item['text']}): nothing in DISCOVERIES SO FAR answers it")
1022
+ gaps.append(f"Item {n} ({item['text']}): {UNANSWERED_GAP}")
905
1023
  elif missing:
906
1024
  gaps.append(f"Item {n} ({item['text']}): missing {missing}")
907
1025
  # A number the item asks for is counted, not judged: a list run recorded only LibreOffice, twice, and the
908
1026
  # check found nothing missing from "three free, open-source alternatives"
909
1027
  wanted = asked_count(item["text"])
910
1028
  names = list(dict.fromkeys(str(t["name"]).strip() for t in things))
911
- # Only with a real condition: the model lists every thing it names only for those
912
- if wanted and conditions and names and len(names) < wanted:
1029
+ # Also without a condition: an owl task finished from one discovery naming two tribes for "at least three
1030
+ # cultures", with the two named as things and nothing missing, because the count ran only with conditions
1031
+ if wanted and names and len(names) < wanted:
913
1032
  gaps.append(f"Item {n} ({item['text']}): only {len(names)} of {wanted} found ({', '.join(names)})")
914
1033
  for thing in things:
915
1034
  proof = str(thing.get("proof", ""))
@@ -921,7 +1040,7 @@ async def coverage_gaps(client, objective, checklist, journal, only=None):
921
1040
  unmet = [c for c in conditions if not (copied and states(proof, c))]
922
1041
  if unmet:
923
1042
  condition = " and ".join(unmet)
924
- unproven.append(f"{thing['name']} (not shown to be {condition})")
1043
+ unproven.append((str(thing["name"]).strip(), condition))
925
1044
  gaps.append(f"Item {n}: {thing['name']}: no words in DISCOVERIES SO FAR show it is {condition}. SEARCH "
926
1045
  f"for '{thing['name']} {condition}' to check it, or replace it with one that is")
927
1046
  return gaps, unanswered, unproven
@@ -943,9 +1062,17 @@ async def coverage_refusal(client, objective, tried, journal):
943
1062
  # final result said nothing was found
944
1063
  gaps = ["Nothing has been recorded for any checklist item yet"]
945
1064
  tried["unanswered"] = [n for n, item in enumerate(checklist, 1) if not OPINION_REQUEST.search(item["text"])]
1065
+ # Only gaps the code can confirm send the agent back: an item nothing was copied for, fewer things than the item
1066
+ # asks for, or nothing recorded at all. About half of the send-backs for the model's other verdicts were wrong in
1067
+ # four suite runs ("missing official documentation" on a comparison, "H2O, O2" already recorded, "free" on a
1068
+ # correct list of open-source suites) and cost a finish each; those verdicts now only shape the final result
1069
+ hard = [g for g in gaps if g.endswith(UNANSWERED_GAP) or re.search(r"\): only \d+ of \d+ found", g)
1070
+ or g.startswith("Nothing has been recorded")]
1071
+ if gaps:
1072
+ print("⚠️ Gaps found:\n" + "\n".join(f" - {g}{'' if g in hard else ' (not sent back)'}" for g in gaps))
1073
+ gaps = hard
946
1074
  if not gaps:
947
1075
  return None
948
- print("⚠️ Gaps found:\n" + "\n".join(f" - {g}" for g in gaps))
949
1076
  return "BLOCKED_COVERAGE", ("Before finishing, each checklist item was checked against DISCOVERIES SO FAR, and these "
950
1077
  "gaps were found:\n" + "\n".join(f"- {g}" for g in gaps) + "\nWork on them first: DISCOVER "
951
1078
  "the missing information, or words that show a condition is met, from the current page if "
@@ -1058,7 +1185,10 @@ def journal_line(e):
1058
1185
  detail += f" (landed on {destination})"
1059
1186
  line = f"T{e.get('turn')} {action} {detail}".rstrip()
1060
1187
  if e.get("reason") or e.get("error"):
1061
- line += f" -> {e.get('reason') or e.get('error')}"
1188
+ # Shortened like the details: the model saw the full refusal when it happened, and repeating it in every
1189
+ # fresh prompt made the journal the largest part of one (up to 7,000 characters, mostly read-on checks
1190
+ # and the finish and coverage checks with their lists), at about 8 tokens a second
1191
+ line += f" -> {shorten(e.get('reason') or e.get('error'), MAX_JOURNAL_REASON_CHARS)}"
1062
1192
  thought = e.get("thought", "")
1063
1193
  if thought:
1064
1194
  line += f" | thought: {thought[:MAX_THOUGHT_CHARS] + '...' if len(thought) > MAX_THOUGHT_CHARS else thought}"
@@ -1335,8 +1465,9 @@ async def compose_final_result(client, objective, journal, checklist=None, not_f
1335
1465
  parts += ("### NOT FOUND (no discovery answers these; they are reported after your result)\n"
1336
1466
  + "\n".join(f"- {text}" for text in unfound) + "\n\n")
1337
1467
  if unproven:
1338
- parts += ("### NOT SHOWN TO MEET THE CONDITIONS (do not list these; they are reported after your result)\n"
1339
- + "\n".join(f"- {text}" for text in unproven) + "\n\n")
1468
+ parts += ("### NOT SHOWN TO MEET THE CONDITIONS (the discoveries do not show these meet them; if you list one, "
1469
+ "say so right after it)\n" + "\n".join(f"- {name}: not shown to be {cond}" for name, cond in unproven)
1470
+ + "\n\n")
1340
1471
  request = (f"### OBJECTIVE\n{objective}\n\n{parts}### DISCOVERIES\n{listing}\n\n"
1341
1472
  "Write the complete final result the OBJECTIVE asks for: do exactly what it asks (for example answer "
1342
1473
  "the question, write the summary or the plan, or give the opinion). Rules:\n"
@@ -1385,8 +1516,27 @@ async def compose_final_result(client, objective, journal, checklist=None, not_f
1385
1516
  text += "\n\n" + (paragraph if paragraph.lower().startswith("my view:") else f"My view: {paragraph}")
1386
1517
  if unfound:
1387
1518
  text += "\n\nNot found in the pages read:\n" + "\n".join(f"- {t}" for t in unfound)
1388
- if unproven:
1389
- text += "\n\nLeft out, not shown to meet the conditions in the pages read:\n" + "\n".join(f"- {t}" for t in unproven)
1519
+ return with_caveats(text, unproven)
1520
+
1521
+ def with_caveats(text, unproven):
1522
+ """The final result with each thing not shown to meet a condition marked where it is first named.
1523
+
1524
+ Leaving such things out dropped correct answers along with wrong ones: a page said "open-source" of
1525
+ LibreOffice, ONLYOFFICE and Apache OpenOffice, but not "free". So they stay, marked by the code, which does not
1526
+ rely on the final result to say it; things the result does not name are listed after it.
1527
+ """
1528
+ unnamed = []
1529
+ for name, condition in unproven:
1530
+ note = f"(not shown in the pages read to be {condition})"
1531
+ match = re.search(re.escape(name) + r"(\*\*)?", text, re.I)
1532
+ if not match:
1533
+ unnamed.append(f"- {name}: {note[1:-1]}")
1534
+ # Only a note right after this name counts as already there: checked against the whole text, the note added
1535
+ # to OnlyOffice left WPS Office, with the same condition, unmarked in a list run's result
1536
+ elif not text[match.end():].lstrip().lower().startswith(note.lower()):
1537
+ text = text[:match.end()] + f" {note}" + text[match.end():]
1538
+ if unnamed:
1539
+ text += "\n\nNot shown in the pages read to meet the conditions:\n" + "\n".join(unnamed)
1390
1540
  return text
1391
1541
 
1392
1542
  async def load_page(page, url=None):
@@ -1582,10 +1732,7 @@ async def main():
1582
1732
  set_note(conversation, f"RESULT: Your DISCOVER was recorded as discovery [{number}]{linked_note}. "
1583
1733
  f"{where_you_are(page_state, current_url)} The page is unchanged.\n"
1584
1734
  f"OBJECTIVE CHECKLIST now:\n{checklist_lines(tried['checklist'])}\n"
1585
- "Do not record the same information again. If DISCOVERIES SO FAR now cover "
1586
- "every item of the OBJECTIVE CHECKLIST, choose FINISH now; do not read further "
1587
- "for extra or merely interesting details. If some items are still not covered, "
1588
- "work on those next instead of adding more detail to items already covered.",
1735
+ f"Do not record the same information again. {after_discovery_advice(tried['checklist'])}",
1589
1736
  current_url)
1590
1737
 
1591
1738
  # A single refusal gets a short warning; repeated refusals get an explicit list of other options
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localagentbrowser
3
- Version: 1.57
3
+ Version: 1.61
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
@@ -199,19 +199,19 @@ does not generalise; the [changelog](https://github.com/SnowFXXX/LocalAgentBrows
199
199
 
200
200
  Since 1.53, every change is measured on the same seven objectives, one of each kind above, run one after the other
201
201
  with a time limit each (`tools/regression.py`, see [Testing](#testing)). Single runs vary with the pages a search
202
- turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 were internal steps measured this way
203
- and released together as 1.57; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
202
+ turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 and 1.58 to 1.60 were internal steps
203
+ measured this way and released with 1.57 and 1.61; [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md)
204
204
  has every step's results and what each one showed.
205
205
 
206
- | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) |
207
- |---|---|---|---|
208
- | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min |
209
- | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" |
210
- | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for |
211
- | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time |
212
- | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited |
213
- | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for |
214
- | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one |
206
+ | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) |
207
+ |---|---|---|---|---|
208
+ | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min |
209
+ | List of three office suites | Time limit | Finished, 30 min; but WPS Office is not open source | Time limit: three correct open-source suites by turn 7, sent back because no words showed them to be "free" | Finished, 21 min: LibreOffice, WPS Office and OnlyOffice, each marked "not shown in the pages read to be free and open-source" |
210
+ | Comparison and recommendation | Time limit | Finished, 39 min | Time limit, after a send-back for "official documentation", which the objective does not ask for | Finished, 26 min, setup and administration from the part of a long page the outline pointer chose |
211
+ | How-to (cold brew from 1.55; repotting before) | Time limit | Time limit | Finished, 36 min; but "how long it keeps" answered with the steeping time | Finished, 33 min: every step, the 16-hour steep and the shelf life, cited |
212
+ | Explanation (photosynthesis) | Time limit | Finished, 13 min, but thin: no oxygen or water | Finished, 59 min: inputs, outputs and both stages, cited | Finished, 39 min |
213
+ | Opinion (owls) | Time limit | Finished, 38 min | Time limit: its first page covered two of the three cultures asked for | Finished, 42 min: four cultures, cited, with a view |
214
+ | Documentation (venv, official docs) | Finished, 15 min, from a tutorial site | Time limit (30 min) | Finished, 37 min, all from python.org; but the Windows line repeats the create command instead of the activate one | Time limit: all four items found from python.org, then it read on |
215
215
 
216
216
  ## How it works
217
217
 
@@ -323,9 +323,9 @@ Measured on an Intel i5-9500 (6 cores, no GPU) with Qwen2.5-7B-Instruct Q4_K_M a
323
323
  | Coverage check before finishing (all checklist items) | about 1–3 min |
324
324
  | Writing the final result (once, at the end) | 50–190 s, depending on how much was found |
325
325
 
326
- Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 13 to
327
- 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 21 to 44 when they finished;
328
- and the explanation 13 to 59. A GPU or a smaller model will be much faster.
326
+ Whole runs vary with the pages a search turns up. In the regression runs since 1.53, a factual question took 9 to
327
+ 19 minutes of model time; the list, comparison, how-to, opinion and documentation tasks 17 to 44 when they finished;
328
+ and the explanation 13 to 59. A whole suite of seven tasks took 171 to 272 minutes. A GPU or a smaller model will be much faster.
329
329
 
330
330
  ## FAQ
331
331
 
@@ -363,15 +363,17 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
363
363
  - **Judging whether a finding covers an item is the weakest area.** The model's verdict is unreliable both ways:
364
364
  on 1.53 the explanation finished from one overview paragraph without oxygen or water, and in the regression runs
365
365
  since 1.54 about half of the coverage check's send-backs were for gaps that were not real ("missing official
366
- documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). The code now verifies what
367
- it can (an item nothing answers, a count, word-for-word proof of a condition), but each wrong send-back still
368
- costs a finish on this hardware, since a send-back costs 8–10 minutes.
366
+ documentation" on a comparison that did not ask for it, "H2O, O2" already recorded). Since 1.58 the agent is sent
367
+ back only for what the code verifies (an item nothing answers, fewer things than asked for); the model's other
368
+ verdicts only shape the final result, so a thin answer can still pass (on 1.61 the comparison's "many
369
+ simultaneous users" part is short).
369
370
  - **Conditions in the objective.** A small model tends to drop conditions such as "open source" or "using the
370
371
  official documentation": a list of open-source office suites once included FreeOffice (free but not open
371
372
  source), and a task that asked for the official Python documentation used a tutorial site. The objective
372
373
  checklist keeps the conditions in view (the documentation task uses python.org since 1.55), and a thing not
373
- shown in the findings to meet a condition is left out of the result and listed after it. The check is strict:
374
- on 1.57 it sent back three correct open-source suites because no words showed them to be "free". Check the
374
+ shown in the findings to meet a condition is marked where it is named ("not shown in the pages read to be free
375
+ and open-source"). The mark says what the pages read did not show, not that the thing fails the condition: on
376
+ 1.61 it marked LibreOffice and OnlyOffice, which are open source, as well as WPS Office, which is not. Check the
375
377
  result against any conditions you set.
376
378
  - **A small model's judgement is the ceiling.** It sometimes drifts to loosely related pages, prefers
377
379
  secondary sources or stays with one source too long, and results vary with the first pages a search turns
@@ -402,8 +404,10 @@ turn that brings in a new page takes a few minutes. See [Performance](#performan
402
404
  - [x] Keep a fixed checklist of the objective's parts instead of re-splitting it at every check (1.53)
403
405
  - [ ] Check that a finding really covers its checklist item, including conditions like "open source" (partly, since
404
406
  1.57: a coverage check before finishing, with what the code can verify)
405
- - [ ] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
406
- result (the next step after 1.57)
407
+ - [x] Send the agent back only for gaps the code verifies, and let the model's other verdicts only shape the final
408
+ result (1.58, released in 1.61)
409
+ - [x] Point the agent to the part of a long page whose outline names an item it still needs (1.61)
410
+ - [ ] Keep the advice to finish when a conversation starts over from a fresh prompt (the next step after 1.61)
407
411
  - [ ] Rewind dead ends: save the conversation before an expensive step such as reading a long page, and if it
408
412
  leads nowhere, return to that point with a short note instead of carrying the unused page text in every later
409
413
  prompt (LM Studio reuses its cache up to the saved point)
@@ -456,8 +460,8 @@ the [Code of Conduct](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CO
456
460
  ## Project history
457
461
 
458
462
  Every released version is a tagged commit, and [CHANGELOG.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/CHANGELOG.md) describes what changed in each one and
459
- why, with the measurements behind the decisions. Versions 1.54 to 1.56 were internal steps, each measured on the
460
- regression suite and released together as 1.57; their results are in
463
+ why, with the measurements behind the decisions. Versions 1.54 to 1.56 and 1.58 to 1.60 were internal steps, each
464
+ measured on the regression suite and released with 1.57 and 1.61; their results are in
461
465
  [docs/regression-history.md](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/docs/regression-history.md).
462
466
 
463
467
  ## Acknowledgements
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "localagentbrowser"
7
- version = "1.57"
7
+ version = "1.61"
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"
@@ -72,6 +72,34 @@ class TestChecksUseTheChecklist:
72
72
  assert "serves the OBJECTIVE (above all the checklist items with nothing recorded yet: 1. What goes in), or write 'none'" in reason
73
73
  assert "Where it happens" not in reason
74
74
 
75
+ def test_read_on_check_points_to_the_outline_part_naming_an_open_item(self):
76
+ tried = {"queries": [], "urls": [], "checklist": checklist("Compare SQLite and PostgreSQL setup and administration",
77
+ "Compare SQLite and PostgreSQL features")}
78
+ outline = ["SQLite vs PostgreSQL / Why", "What is SQLite? / What is PostgreSQL?", "SQLite vs PostgreSQL: Similarities",
79
+ "SQLite vs PostgreSQL: Data types", "SQLite / PostgreSQL / Licenses / Administration",
80
+ "Performance / Features", "Scalability"]
81
+ page = {"url": DOCS, "parts": ["x"] * 7, "outline": outline, "index": 0, "seen_parts": {0}}
82
+ reason = ab.check_decision("READ_MORE", "", discoveries("x"), tried, page, DOCS)[1]
83
+ assert ("If there are none, repeat your READ_MORE: it will show part 5, not the next part, because the Page "
84
+ "Outline names 'administration' there") in reason
85
+ # Repeated plainly, the READ_MORE reads the part named; a part number the model gives is kept
86
+ assert ab.read_more_target("", page) == 4 and ab.read_more_target("2", page) == 1
87
+ assert ab.read_more_target("", dict(page, index=1)) == 2
88
+ # Words in many labels ("sqlite") and item verbs ("compare") point nowhere
89
+ assert ab.outline_pointer(dict(page, seen_parts={0, 4, 5}), tried["checklist"]) is None
90
+ # A label taken from the first line of code is not a heading
91
+ code = dict(page, parts=["x"] * 4 + ["```\n Administration of the pool\n"] + ["x"] * 2)
92
+ assert ab.outline_pointer(code, tried["checklist"]) == (6, "features")
93
+
94
+ def test_read_on_check_asks_for_finish_once_every_item_has_its_discoveries(self):
95
+ tried = {"queries": [], "urls": [], "checklist": checklist("What goes in", "Where it happens")}
96
+ ab.link_discovery(tried["checklist"], [1], 1)
97
+ ab.link_discovery(tried["checklist"], [2], 2)
98
+ page = {"url": DOCS, "parts": ["a", "b"], "index": 0, "seen_parts": {0}}
99
+ reason = ab.check_decision("READ_MORE", "", discoveries("x", "y"), tried, page, DOCS)[1]
100
+ assert "already has the discoveries it asks for. Choose FINISH now" in reason
101
+ assert "repeat your READ_MORE" not in reason
102
+
75
103
 
76
104
  class TestFinalResult:
77
105
  def test_checklist_is_given_with_a_rule_to_cover_it(self):
@@ -119,9 +147,16 @@ class TestCoverageGaps:
119
147
  "'OnlyOffice free and open-source' to check it, or replace it with one that is"]
120
148
 
121
149
  def test_long_descriptions_are_not_conditions(self):
122
- reply = verdict(conditions=["in different cultures or religions"], things=[("Western cultures", "")])
150
+ reply = verdict(conditions=["in different cultures or religions"],
151
+ things=[("Western cultures", ""), ("Hopi", ""), ("Greece", "")])
123
152
  assert gaps(["How at least three different cultures or religions viewed owls"], [reply])[0] == []
124
153
 
154
+ def test_a_short_count_is_found_without_a_condition(self):
155
+ reply = verdict(things=[("Dakota Hidatsa Indians", "the Dakota Hidatsa"), ("Hopis Indians", "the Hopis")])
156
+ assert gaps(["How at least three different cultures viewed owls"], [reply])[0] == [
157
+ "Item 1 (How at least three different cultures viewed owls): only 2 of 3 found (Dakota Hidatsa Indians, "
158
+ "Hopis Indians)"]
159
+
125
160
  def test_conditions_the_item_does_not_state_are_ignored(self):
126
161
  reply = verdict(conditions=["open-source"], things=[("WPS Office", "")])
127
162
  assert gaps(["What each one is best at"], [reply])[0] == []
@@ -132,13 +167,22 @@ class TestCoverageGaps:
132
167
 
133
168
 
134
169
  class TestCoverageRefusal:
135
- def test_sent_back_once_with_the_gaps(self):
136
- client, _ = fake_client([verdict(missing="oxygen")])
170
+ def test_sent_back_once_for_an_item_nothing_answers(self):
171
+ client, _ = fake_client([verdict(evidence="", missing="oxygen")])
137
172
  tried = {"checklist": checklist("What comes out")}
138
173
  refusal = asyncio.run(ab.coverage_refusal(client, "objective", tried, discoveries("x")))
139
- assert refusal[0] == "BLOCKED_COVERAGE" and "- Item 1 (What comes out): missing oxygen" in refusal[1]
174
+ assert refusal[0] == "BLOCKED_COVERAGE"
175
+ assert "- Item 1 (What comes out): nothing in DISCOVERIES SO FAR answers it" in refusal[1]
140
176
  assert asyncio.run(ab.coverage_refusal(client, "objective", tried, discoveries("x"))) is None
141
177
 
178
+ def test_the_model_s_other_verdicts_do_not_send_back(self):
179
+ reply = verdict(missing="official documentation", conditions=["free"],
180
+ things=[("ONLYOFFICE", ""), ("LibreOffice", ""), ("OpenOffice", "")])
181
+ client, _ = fake_client([reply])
182
+ tried = {"checklist": checklist("Name three free suites")}
183
+ assert asyncio.run(ab.coverage_refusal(client, "objective", tried, discoveries("ONLYOFFICE is open source."))) is None
184
+ assert tried["unproven_items"] == [1], "the unmet condition still counts for the final result"
185
+
142
186
  def test_skipped_after_an_exhausted_search(self):
143
187
  client, model = fake_client([])
144
188
  assert asyncio.run(ab.coverage_refusal(client, "o", {"checklist": checklist("a"), "search_exhausted": True},
@@ -158,6 +202,8 @@ class TestCountedItems:
158
202
  assert ab.asked_count("Name three free, open-source alternatives") == 3
159
203
  assert ab.asked_count("Find at least 4 examples") == 4
160
204
  assert ab.asked_count("How tall is it in meters?") is None
205
+ assert ab.asked_count("Say how to care for the plant during the first two weeks") is None
206
+ assert ab.asked_count("Name two drinks made with 2 cups of water") == 2
161
207
 
162
208
  def test_fewer_different_things_than_asked_is_a_gap(self):
163
209
  reply = verdict(conditions=["open-source"], things=[("LibreOffice", "LibreOffice is free and open source"),
@@ -220,12 +266,25 @@ class TestUnprovenThings:
220
266
  tried = {"checklist": items, "unproven_items": [1]}
221
267
  found = discoveries("LibreOffice is free and open source. WPS Office has a familiar interface.")
222
268
  missing, unproven = asyncio.run(ab.not_found_items(client, "o", tried, found))
223
- assert missing == [] and unproven == ["WPS Office (not shown to be open-source)"]
269
+ assert missing == [] and unproven == [("WPS Office", "open-source")]
224
270
 
225
- client, model = fake_client(["1. LibreOffice [1]"])
271
+ client, model = fake_client(["1. **LibreOffice** [1]\n2. **WPS Office**"])
226
272
  text = asyncio.run(ab.compose_final_result(client, "o", found, items, [], unproven))
227
- assert "### NOT SHOWN TO MEET THE CONDITIONS (do not list these" in model.calls[0][-1]["content"]
228
- assert text.endswith("Left out, not shown to meet the conditions in the pages read:\n- WPS Office (not shown to be open-source)")
273
+ assert "- WPS Office: not shown to be open-source" in model.calls[0][-1]["content"]
274
+ assert text == "1. **LibreOffice** [1]\n2. **WPS Office** (not shown in the pages read to be open-source)"
275
+
276
+ def test_unnamed_things_are_listed_after_the_result_and_notes_are_not_doubled(self):
277
+ text = ab.with_caveats("LibreOffice [1]. ONLYOFFICE (not shown in the pages read to be free).",
278
+ [("ONLYOFFICE", "free"), ("WPS Office", "open-source")])
279
+ assert text == ("LibreOffice [1]. ONLYOFFICE (not shown in the pages read to be free).\n\n"
280
+ "Not shown in the pages read to meet the conditions:\n- WPS Office: not shown in the pages read "
281
+ "to be open-source")
282
+
283
+ def test_things_with_the_same_condition_are_each_marked(self):
284
+ note = "(not shown in the pages read to be free and open-source)"
285
+ text = ab.with_caveats("1. **OnlyOffice** [1]\n2. **WPS Office** [2]",
286
+ [("OnlyOffice", "free and open-source"), ("WPS Office", "free and open-source")])
287
+ assert text == f"1. **OnlyOffice** {note} [1]\n2. **WPS Office** {note} [2]"
229
288
 
230
289
 
231
290
  def test_opinion_items_are_shown_as_written_at_finish():
@@ -249,3 +308,39 @@ class TestReallyMissing:
249
308
 
250
309
  def test_co2_does_not_count_as_o2(self):
251
310
  assert ab.really_missing("O2", discoveries("Plants take in CO2.")) == "O2"
311
+
312
+
313
+ def test_descriptions_are_not_things():
314
+ reply = verdict(conditions=["open-source"], things=[
315
+ ("LibreOffice", "LibreOffice is free and open source"),
316
+ ("OnlyOffice", ""),
317
+ (".docx/.xlsx/.pptx compatibility is genuinely excellent\nBuilt-in real-time collaboration", "")])
318
+ client, _ = fake_client([reply])
319
+ result, _, unproven = asyncio.run(ab.coverage_gaps(client, "o", checklist("Name three free, open-source suites"),
320
+ discoveries("LibreOffice is free and open source.")))
321
+ assert "Item 1 (Name three free, open-source suites): only 2 of 3 found (LibreOffice, OnlyOffice)" in result
322
+ assert unproven == [("OnlyOffice", "open-source")]
323
+
324
+
325
+ class TestAfterDiscoveryAdvice:
326
+ def test_says_finish_when_every_item_has_what_it_asks_for(self):
327
+ items = checklist("Find how at least three cultures viewed owls", "Give your own opinion on why")
328
+ for n in (1, 2):
329
+ ab.link_discovery(items, [1], n)
330
+ assert "If DISCOVERIES SO FAR now cover" in ab.after_discovery_advice(items), "two of three is not enough"
331
+ ab.link_discovery(items, [1], 3)
332
+ assert ab.after_discovery_advice(items).startswith("Every item of the OBJECTIVE CHECKLIST now has the discoveries")
333
+
334
+ def test_an_item_without_discoveries_keeps_the_usual_advice(self):
335
+ items = checklist("Setup", "Features")
336
+ ab.link_discovery(items, [1], 1)
337
+ assert not ab.every_item_has_discoveries(items)
338
+
339
+
340
+ def test_one_discovery_linked_to_every_item_is_not_enough():
341
+ items = checklist("What goes in", "What comes out", "Light reactions", "Calvin cycle")
342
+ ab.link_discovery(items, [1, 2, 3, 4], 1)
343
+ assert not ab.every_item_has_discoveries(items)
344
+ for n in (2, 3, 4):
345
+ ab.link_discovery(items, [n], n)
346
+ assert ab.every_item_has_discoveries(items)
@@ -72,3 +72,10 @@ def test_restore_path_escapes():
72
72
  row = "Medium plants\t4-5 inches\tChoose a pot just 1-2 inches wider"
73
73
  assert ab.restore_path_escapes(row) == row, "tabs from a page table stay"
74
74
  assert ab.restore_path_escapes("C:" + "\t" + "emp") == r"C:\temp"
75
+
76
+
77
+ def test_long_refusal_reasons_are_shortened_in_the_journal():
78
+ reason = "Before reading on: you have recorded nothing from part 3. " + "List each fact. " * 40
79
+ line = ab.journal_line({"turn": 8, "action": "REFUSED_IN_TURN", "requested": "READ_MORE", "reason": reason})
80
+ assert line.startswith("T8 REFUSED_IN_TURN READ_MORE -> Before reading on: you have recorded nothing from part 3.")
81
+ assert len(line) < 200 and line.endswith("...")
@@ -144,10 +144,10 @@ def test_discoveries_are_linked_to_checklist_items(monkeypatch, tmp_path):
144
144
 
145
145
 
146
146
  def test_a_coverage_send_back_starts_the_next_turn_fresh(monkeypatch, tmp_path):
147
- gap = json.dumps({"evidence": "[1] fact from a", "missing": "the second fact", "conditions": [], "things": []})
147
+ gap = json.dumps({"evidence": "", "missing": "the second fact", "conditions": [], "things": []})
148
148
  script = [reply("SEARCH", "docs a"), reply("NAVIGATE", A), reply("DISCOVER", "fact from a"),
149
149
  reply("FINISH", "done"), reply("FINISH", "done"), # finish check, then repeated
150
- gap, COVERED, # coverage check: a gap in item 1 -> sent back
150
+ gap, COVERED, # coverage check: nothing answers item 1 -> sent back
151
151
  reply("FINISH", "done"), reply("FINISH", "done"), # next turn, fresh prompt
152
152
  COVERED, COVERED, # nothing linked: both items checked once more
153
153
  "Final [1]."]
@@ -51,7 +51,7 @@ def test_render_part_footer():
51
51
  assert "[End of page]" in ab.render_part(page)
52
52
 
53
53
 
54
- MENU = [{"text": f"Menu item {i}", "href": f"https://site.example/menu{i}"} for i in range(15)]
54
+ MENU = [{"text": f"Menu item {i}", "href": f"https://site.example/menu{i}"} for i in range(ab.MAX_LINKS)]
55
55
  CONTENT = [{"text": f"Content link {i}", "href": f"https://site.example/content{i}"} for i in range(5)]
56
56
 
57
57
 
@@ -66,9 +66,23 @@ def test_next_page_on_the_same_site_lists_new_links_instead_of_the_menu():
66
66
  asyncio.run(ab.extract_page_links(FakeLinksPage("https://site.example/a", MENU + CONTENT), shown))
67
67
  links = asyncio.run(ab.extract_page_links(FakeLinksPage("https://site.example/b", MENU + CONTENT), shown))
68
68
  assert links.count("->") == 5 and "Content link 0" in links and "Menu item" not in links
69
- assert "(15 links already listed" in links
69
+ assert f"({ab.MAX_LINKS} links already listed" in links
70
70
 
71
71
 
72
72
  def test_without_a_running_conversation_the_full_list_is_shown():
73
73
  links = asyncio.run(ab.extract_page_links(FakeLinksPage("https://site.example/b", MENU + CONTENT)))
74
74
  assert links.count("->") == ab.MAX_LINKS and "not repeated" not in links
75
+
76
+
77
+ def test_links_whose_text_is_html_are_skipped():
78
+ images = [{"text": '<img src="https://site.example/x.jpg">', "href": f"https://shop.example/{i}"} for i in range(5)]
79
+ links = asyncio.run(ab.extract_page_links(FakeLinksPage("https://site.example/a", images + CONTENT)))
80
+ assert "<img" not in links and links.count("->") == 5
81
+
82
+
83
+ def test_links_with_a_few_words_come_before_menu_items():
84
+ menu = [{"text": f"Menu{i}", "href": f"https://site.example/m{i}"} for i in range(10)]
85
+ article = [{"text": "How to repot a houseplant", "href": "https://site.example/repot"}]
86
+ links = asyncio.run(ab.extract_page_links(FakeLinksPage("https://site.example/a", menu + article)))
87
+ assert links.splitlines()[0] == "- How to repot a houseplant -> https://site.example/repot"
88
+ assert links.count("->") == ab.MAX_LINKS
@@ -110,3 +110,9 @@ def test_task_ids_are_unique_and_every_kind_has_a_core_task():
110
110
  ids = [t["id"] for t in rg.TASKS]
111
111
  assert len(ids) == len(set(ids))
112
112
  assert {t["kind"] for t in rg.TASKS} == {t["kind"] for t in rg.TASKS if t["core"]}
113
+
114
+
115
+ def test_a_caveated_forbidden_item_is_not_a_missed_condition():
116
+ result = "1. LibreOffice [1]\n2. OnlyOffice [1]\n3. **FreeOffice** (not shown in the pages read to be free and open-source)"
117
+ assert rg.score_run(TASK, journal(result, "LibreOffice and OnlyOffice ..."))["forbidden"] == []
118
+ assert rg.score_run(TASK, journal("3. FreeOffice [1]", "x"))["forbidden"] == ["freeoffice"]
@@ -210,7 +210,10 @@ def score_run(task, journal, console="", checklist=None):
210
210
  "unverified_numbers": ab.unverified_numbers(result, found) if finish else [],
211
211
  "gap_statements": [m.group(0) for m in GAP_STATEMENT.finditer(result)],
212
212
  "missing": ["/".join(group[:3]) for group in task.get("expect", []) if not any(word in low for word in group)],
213
- "forbidden": [word for word in task.get("forbid", []) if word in low],
213
+ # A forbidden item marked by the agent's own caveat ("FreeOffice (not shown in the pages read to be ...") is
214
+ # flagged to the reader, so it does not count as a missed condition
215
+ "forbidden": [word for word in task.get("forbid", []) if word in low
216
+ and not re.search(re.escape(word) + r"\W{0,3}\(not shown in the pages read", low)],
214
217
  # How far a run got, so one that was about to finish is not read the same as one that was stuck
215
218
  "finish_attempts": sum(1 for e in journal if e.get("action") in ("FINISH", "BLOCKED_FINISH", "CONFIRM_FINISH")
216
219
  or str(e.get("requested", "")).startswith("FINISH")),