localagentbrowser 1.63__tar.gz → 1.64__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 (43) hide show
  1. {localagentbrowser-1.63 → localagentbrowser-1.64}/CHANGELOG.md +57 -1
  2. {localagentbrowser-1.63/localagentbrowser.egg-info → localagentbrowser-1.64}/PKG-INFO +19 -11
  3. {localagentbrowser-1.63 → localagentbrowser-1.64}/README.md +18 -10
  4. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/agent.py +9 -3
  5. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/checks.py +17 -2
  6. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/config.py +4 -0
  7. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/coverage.py +106 -7
  8. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/grounding.py +36 -4
  9. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/pages.py +92 -1
  10. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/prompts.py +17 -5
  11. localagentbrowser-1.64/localagentbrowser/report.py +835 -0
  12. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/text.py +9 -0
  13. {localagentbrowser-1.63 → localagentbrowser-1.64/localagentbrowser.egg-info}/PKG-INFO +19 -11
  14. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser.egg-info/SOURCES.txt +3 -1
  15. {localagentbrowser-1.63 → localagentbrowser-1.64}/pyproject.toml +1 -1
  16. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/golden/prompts.json +26 -16
  17. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_checklist.py +429 -3
  18. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_decisions.py +26 -0
  19. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_golden_prompts.py +1 -0
  20. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_main_loop.py +5 -3
  21. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_model_turns.py +2 -2
  22. localagentbrowser-1.64/tests/test_page_text.py +165 -0
  23. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_regression.py +22 -0
  24. localagentbrowser-1.64/tests/test_show_report.py +30 -0
  25. {localagentbrowser-1.63 → localagentbrowser-1.64}/tools/regression.py +58 -14
  26. localagentbrowser-1.64/tools/windows/show_report.py +155 -0
  27. localagentbrowser-1.63/localagentbrowser/report.py +0 -316
  28. localagentbrowser-1.63/tests/test_page_text.py +0 -89
  29. {localagentbrowser-1.63 → localagentbrowser-1.64}/LICENSE +0 -0
  30. {localagentbrowser-1.63 → localagentbrowser-1.64}/MANIFEST.in +0 -0
  31. {localagentbrowser-1.63 → localagentbrowser-1.64}/agent_browser.py +0 -0
  32. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/__init__.py +0 -0
  33. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser/checklist.py +0 -0
  34. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser.egg-info/dependency_links.txt +0 -0
  35. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser.egg-info/entry_points.txt +0 -0
  36. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser.egg-info/requires.txt +0 -0
  37. {localagentbrowser-1.63 → localagentbrowser-1.64}/localagentbrowser.egg-info/top_level.txt +0 -0
  38. {localagentbrowser-1.63 → localagentbrowser-1.64}/setup.cfg +0 -0
  39. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/conftest.py +0 -0
  40. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/fakes.py +0 -0
  41. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_duplicates.py +0 -0
  42. {localagentbrowser-1.63 → localagentbrowser-1.64}/tests/test_journal.py +0 -0
  43. {localagentbrowser-1.63 → localagentbrowser-1.64}/tools/live_test.py +0 -0
@@ -1,11 +1,67 @@
1
1
  # Changelog
2
2
 
3
- Every version of LocalAgentBrowser, newest first. Each released version is also a tagged commit (`v1.11` … `v1.62`); 1.54 to 1.56 and 1.58 to 1.60 were internal steps
3
+ Every version of LocalAgentBrowser, newest first. Each released version is also a tagged commit (`v1.11` … `v1.63`); 1.54 to 1.56 and 1.58 to 1.60 were internal steps
4
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.64] - 2026-11-08
10
+ The theme is **silent omission** ([issue #11](https://github.com/SnowFXXX/LocalAgentBrowser/issues/11)): a part of
11
+ the objective dropping out of the final answer without being marked "not found". It was measured on two PCs (PC A
12
+ and PC B, both Intel i5-9500, no GPU) and then on four models with identical code.
13
+
14
+ ### Added
15
+ - **Windows launcher and shortcuts.** `tools/windows/LocalAgentBrowser.cmd` checks that LM Studio is answering,
16
+ runs the agent in its own folder under `Documents\LocalAgentBrowser runs`, and opens the report when the run ends.
17
+ `tools/windows/make_shortcuts.ps1` adds Start-menu and desktop shortcuts with the project icon (from a clone of
18
+ the repository).
19
+ - **The report opens by itself** when a launcher run ends: `research_report.md` is written next to it as a dark web
20
+ page, in the project site's colors, with the final result, checklist and discoveries in separate cards and
21
+ citations shown as tags (`tools/windows/show_report.py`, standard library only). Windows has no program for `.md`
22
+ files by default.
23
+ - Regression suite: every run folder gets `run_info.json` (PC label from `LAB_PC_LABEL`, commit, and a fingerprint
24
+ of the uncommitted changes), so runs on several PCs can be tied to the exact code they ran.
25
+ - Regression suite: `LAB_BLOCKED_SITES` makes NAVIGATE refuse given sites, for benchmark runs that must not read the
26
+ benchmark's own answer pages.
27
+
28
+ ### Fixed: parts of the objective dropping out of the answer
29
+ - **The coverage check asks for the quote first.** "Does this discovery answer the item?" is now answered by first
30
+ copying the words that answer it, with a rule that words on the same topic but a different aspect don't count.
31
+ On 30 hand-labelled real cases, 25 right instead of 16 (no false "not found", 5 false "found" left).
32
+ - **A part credited to several items only counts where the item's own words appear**, so one quote can't silently
33
+ cover three parts; such an item is sent back as unanswered.
34
+ - **Missing amounts and concrete missing terms send the agent back.** When an item asks for amounts and no discovery
35
+ for it gives one, or the check names a concrete missing term ("O2") that no discovery contains, the agent is told
36
+ to find it, instead of finishing without it (an explanation once finished in 4 turns without oxygen).
37
+ - **Each fact stays under its own part.** A discovery keeps the page heading it came from, including headings inside
38
+ a discovery that crosses page sections; a heading the discovery starts with isn't replaced by the one above;
39
+ bylines, very long "headings" (numbered sentences) and the prompt's own section names don't count as headings or
40
+ names.
41
+ - **Names no discovery gives are cut,** with a note: a short cited heading's name is checked too, word forms need
42
+ an 80% shared start ("PhotoScape" over a PhotoSplice source), and words in a source's web address count as given.
43
+ - **A sentence under one label copied from another subject's sentence is cut** (SQLite's sentence under
44
+ PostgreSQL).
45
+ - **Numbers in words are checked like digits** ("two weeks" needs a 2 in a source, "one cup" equals "1 cup"), unit
46
+ abbreviations count as their words ("5,895 m (19,341 ft)" answers "in meters" and "in feet"), and a time needs
47
+ the same number as a time in a source ("2 weeks" was taken from a "2L jar").
48
+ - **Citations:** citation lines on their own are joined to the line they cite; citations are moved out of code, and
49
+ unclosed code blocks closed, so correct commands aren't cut as invented.
50
+ - A **"Recommendation" section is labelled as the model's view**, like "My view:".
51
+ - **NAVIGATE refuses text that isn't a web address** (a run lost 5 turns on it).
52
+
53
+ ### Fixed: the regression suite's scoring
54
+ - The code's own "Left out, because…" notes are no longer read as the result's claims (commands and uncited lines).
55
+ 4 of 78 earlier run folders were over-counted.
56
+ - A bold label ("**1. Creating a virtual environment:**") is a heading, not an uncited claim.
57
+
58
+ ### Measured
59
+ - 7B on two PCs: 7 of 7 on both PCs with v11 (297.8 and 269.6 model minutes), with no outside knowledge, invented
60
+ numbers or invented commands in any finished result.
61
+ - Four models, same code (v12, 90 minutes per task): Qwen2.5-7B 6 of 7, Llama 3.1 8B 7 of 7, Qwen2.5-14B 5 of 7,
62
+ Qwen2.5-3B 3 of 7. On 1.63 they were 6, 2, 2 and 0 of 7. No finished result used outside knowledge. Details in
63
+ docs/regression-history.md.
64
+
9
65
  ## [1.63] - 2026-11-01
10
66
  ### Added
11
67
  - A model call the server fails is tried again after a wait (5, 15, 30, 60 and 120 seconds, about 4 minutes in all),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: localagentbrowser
3
- Version: 1.63
3
+ Version: 1.64
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
@@ -36,6 +36,7 @@ Dynamic: license-file
36
36
  decides when it's done, all on your own computer.**
37
37
 
38
38
  [![Tests](https://github.com/SnowFXXX/LocalAgentBrowser/actions/workflows/tests.yml/badge.svg)](https://github.com/SnowFXXX/LocalAgentBrowser/actions/workflows/tests.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/localagentbrowser.svg)](https://pypi.org/project/localagentbrowser/)
39
40
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/LICENSE)
40
41
  ![Python 3.10–3.13](https://img.shields.io/badge/python-3.10%E2%80%933.13-blue.svg)
41
42
  ![Runs locally](https://img.shields.io/badge/LLM-local%20(LM%20Studio)-green.svg)
@@ -58,9 +59,11 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
58
59
  - ⚡ **Built for slow hardware.** It reuses LM Studio's prompt cache, so most turns process only what changed.
59
60
  - 📝 **A readable report every time.** `research_report.md` holds the final result and every finding with its
60
61
  source, written however the run ends; `research_log.txt` keeps every action and reason.
61
- - ✅ **Tested.** 251 tests on Linux and Windows, Python 3.10–3.13, run in a few seconds with no model or browser,
62
+ - ✅ **Tested.** 324 tests on Linux and Windows, Python 3.10–3.13, run in a few seconds with no model or browser,
62
63
  plus a [regression suite](#regression-suite) of live runs on seven different kinds of task.
63
64
 
65
+ **New with 1.64:** parts of the objective no longer drop out of the answer unannounced as often (the coverage check asks for the words first; missing amounts and terms send the agent back), measured on two PCs and then four models with identical code: Qwen2.5-7B 6 of 7, Llama 3.1 8B 7 of 7, Qwen2.5-14B 5 of 7 and Qwen2.5-3B 3 of 7 tasks, with no outside knowledge in any finished result ([regression history](docs/regression-history.md)). On Windows, a launcher with Start-menu and desktop shortcuts opens the report by itself when a run ends.
66
+
64
67
  **New with 1.63:** the [project site](https://snowfxxx.github.io/LocalAgentBrowser/), an article,
65
68
  [We Can Do Better: A Small AI That Can't Cover Up](https://snowfxxx.github.io/LocalAgentBrowser/article.html), and a
66
69
  technical paper, [Verification over Trust](https://snowfxxx.github.io/LocalAgentBrowser/paper.html).
@@ -135,6 +138,11 @@ six lessons from 30 versions of keeping a small local model on track.
135
138
  When it finishes, read `research_report.md` in the folder you ran it from. Press Ctrl+C at any time to
136
139
  stop; the report is still written with everything found so far.
137
140
 
141
+ **On Windows**, from a clone of the repository (below), `powershell -ExecutionPolicy Bypass -File
142
+ tools\windows\make_shortcuts.ps1` adds LocalAgentBrowser to the Start menu and the desktop. The launcher checks that
143
+ LM Studio is answering, runs each objective in its own folder under `Documents\LocalAgentBrowser runs`, and opens the
144
+ report as a page in your browser when the run ends.
145
+
138
146
  To work on the code instead, clone the repository and install from it:
139
147
 
140
148
  ```bash
@@ -231,15 +239,15 @@ turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 and
231
239
  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)
232
240
  has every step's results and what each one showed.
233
241
 
234
- | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) |
235
- |---|---|---|---|---|
236
- | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min |
237
- | 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" |
238
- | 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 |
239
- | 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 |
240
- | 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 |
241
- | 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 |
242
- | 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 |
242
+ | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) | 1.64 (quote-first coverage, send-backs for missing amounts and terms) |
243
+ |---|---|---|---|---|---|
244
+ | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min | Finished, 17 min |
245
+ | 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" | Finished, 34 min: GIMP, Krita and Darktable (the list task asks for Photoshop alternatives from 1.64) |
246
+ | 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 | Finished, 49 min |
247
+ | 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 | Finished, 50 min |
248
+ | 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 | Finished, 73 min, oxygen included |
249
+ | 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 | Finished, 49 min |
250
+ | 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 | Time limit: read the reference page part after part without recording (finished in 34 and 42 min in the two v11 runs) |
243
251
 
244
252
  ## How it works
245
253
 
@@ -4,6 +4,7 @@
4
4
  decides when it's done, all on your own computer.**
5
5
 
6
6
  [![Tests](https://github.com/SnowFXXX/LocalAgentBrowser/actions/workflows/tests.yml/badge.svg)](https://github.com/SnowFXXX/LocalAgentBrowser/actions/workflows/tests.yml)
7
+ [![PyPI](https://img.shields.io/pypi/v/localagentbrowser.svg)](https://pypi.org/project/localagentbrowser/)
7
8
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/SnowFXXX/LocalAgentBrowser/blob/main/LICENSE)
8
9
  ![Python 3.10–3.13](https://img.shields.io/badge/python-3.10%E2%80%933.13-blue.svg)
9
10
  ![Runs locally](https://img.shields.io/badge/LLM-local%20(LM%20Studio)-green.svg)
@@ -26,9 +27,11 @@ and tuned to work well with a **small 7B model on a CPU, no GPU required**.
26
27
  - ⚡ **Built for slow hardware.** It reuses LM Studio's prompt cache, so most turns process only what changed.
27
28
  - 📝 **A readable report every time.** `research_report.md` holds the final result and every finding with its
28
29
  source, written however the run ends; `research_log.txt` keeps every action and reason.
29
- - ✅ **Tested.** 251 tests on Linux and Windows, Python 3.10–3.13, run in a few seconds with no model or browser,
30
+ - ✅ **Tested.** 324 tests on Linux and Windows, Python 3.10–3.13, run in a few seconds with no model or browser,
30
31
  plus a [regression suite](#regression-suite) of live runs on seven different kinds of task.
31
32
 
33
+ **New with 1.64:** parts of the objective no longer drop out of the answer unannounced as often (the coverage check asks for the words first; missing amounts and terms send the agent back), measured on two PCs and then four models with identical code: Qwen2.5-7B 6 of 7, Llama 3.1 8B 7 of 7, Qwen2.5-14B 5 of 7 and Qwen2.5-3B 3 of 7 tasks, with no outside knowledge in any finished result ([regression history](docs/regression-history.md)). On Windows, a launcher with Start-menu and desktop shortcuts opens the report by itself when a run ends.
34
+
32
35
  **New with 1.63:** the [project site](https://snowfxxx.github.io/LocalAgentBrowser/), an article,
33
36
  [We Can Do Better: A Small AI That Can't Cover Up](https://snowfxxx.github.io/LocalAgentBrowser/article.html), and a
34
37
  technical paper, [Verification over Trust](https://snowfxxx.github.io/LocalAgentBrowser/paper.html).
@@ -103,6 +106,11 @@ six lessons from 30 versions of keeping a small local model on track.
103
106
  When it finishes, read `research_report.md` in the folder you ran it from. Press Ctrl+C at any time to
104
107
  stop; the report is still written with everything found so far.
105
108
 
109
+ **On Windows**, from a clone of the repository (below), `powershell -ExecutionPolicy Bypass -File
110
+ tools\windows\make_shortcuts.ps1` adds LocalAgentBrowser to the Start menu and the desktop. The launcher checks that
111
+ LM Studio is answering, runs each objective in its own folder under `Documents\LocalAgentBrowser runs`, and opens the
112
+ report as a page in your browser when the run ends.
113
+
106
114
  To work on the code instead, clone the repository and install from it:
107
115
 
108
116
  ```bash
@@ -199,15 +207,15 @@ turns up, so the suite compares versions on all seven at once. 1.54 to 1.56 and
199
207
  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)
200
208
  has every step's results and what each one showed.
201
209
 
202
- | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) |
203
- |---|---|---|---|---|
204
- | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min |
205
- | 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" |
206
- | 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 |
207
- | 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 |
208
- | 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 |
209
- | 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 |
210
- | 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 |
210
+ | Task | 1.52 | 1.53 (objective checklist) | 1.57 (coverage check) | 1.61 (verified send-backs, outline pointer) | 1.64 (quote-first coverage, send-backs for missing amounts and terms) |
211
+ |---|---|---|---|---|---|
212
+ | Factual question | Finished, 12 min | Finished, 14 min | Finished, 13 min | Finished, 17 min | Finished, 17 min |
213
+ | 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" | Finished, 34 min: GIMP, Krita and Darktable (the list task asks for Photoshop alternatives from 1.64) |
214
+ | 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 | Finished, 49 min |
215
+ | 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 | Finished, 50 min |
216
+ | 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 | Finished, 73 min, oxygen included |
217
+ | 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 | Finished, 49 min |
218
+ | 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 | Time limit: read the reference page part after part without recording (finished in 34 and 42 min in the two v11 runs) |
211
219
 
212
220
  ## How it works
213
221
 
@@ -7,7 +7,7 @@ from playwright.async_api import async_playwright
7
7
  from .config import API_KEY, BASE_URL, MAX_CHEAP_RETRIES, MAX_CONVERSATION_TOKENS, MAX_DISCOVERY_PROMPT_CHARS, MAX_FOLLOWUPS, MAX_JSON_RETRIES, MAX_QUERY_WORDS, MAX_VISITS_PER_URL, MODEL, MODEL_RETRY_WAITS, MODEL_TIMEOUT_SECONDS, TURN_MAX_TOKENS, OBJECTIVE_CHECK_EVERY, REPORT_FILE, STALL_TURNS, TURN_PAUSE_SECONDS
8
8
  from .prompts import CUT_OFF, FINAL_RESULT, FIRST_TURN_INSTRUCTION, GIVE_UP_RULE, READ_FIRST, RESPONSE_FORMAT, SYSTEM_PROMPT, TURN_INSTRUCTIONS
9
9
  from .text import extract_message_content, was_cut_off
10
- from .pages import build_environment, count_search_results, determine_url, is_search_page, load_page, normalize_url, read_more_target, render_part, search_query, where_you_are
10
+ from .pages import build_environment, count_search_results, determine_url, is_search_page, load_page, normalize_url, read_more_target, render_part, search_query, section_heading, where_you_are, with_inner_headings
11
11
  from .checklist import after_discovery_advice, check_parts, checklist_lines, link_discovery, split_objective
12
12
  from .coverage import amounts_not_found, coverage_refusal, not_found_items
13
13
  from .checks import check_decision, escalation_message, numbered_discoveries, numbered_lines, refusal_correction, source_note
@@ -319,7 +319,8 @@ async def main():
319
319
  missing, unproven = await not_found_items(client, objective, tried, journal)
320
320
  no_amounts = amounts_not_found(tried.get("checklist"), journal, skip=missing)
321
321
  final = await compose_final_result(client, objective, journal, tried.get("checklist"), missing,
322
- unproven, no_amounts, tried.get("evidence"))
322
+ unproven, no_amounts, tried.get("evidence"),
323
+ tried.get("short_counts"))
323
324
  if final:
324
325
  finish_entry.update(draft=parameter, finding=final)
325
326
  except Exception as err:
@@ -398,8 +399,13 @@ async def main():
398
399
  print(f"📝 Logging critical discovery element: {parameter}")
399
400
  number = sum(1 for e in journal if e.get("action") == "DISCOVER") + 1
400
401
  linked = link_discovery(tried["checklist"], decision.get("checklist_items"), number)
402
+ # Recorded with its section's heading when the copied text does not name it (the heading is page
403
+ # text too): every later check and the final result then see what the lines are about
404
+ heading = section_heading(page_state, parameter)
405
+ inner = with_inner_headings(page_state, parameter)
406
+ recorded = f'(Under the heading "{heading}") {inner}' if heading else inner
401
407
  journal.append({"turn": turn_counter, "thought": thought, "action": "DISCOVER", "source": current_url,
402
- "extracted_data": parameter, "checklist_items": linked})
408
+ "extracted_data": recorded, "checklist_items": linked})
403
409
  page_state["visit_discoveries"] = page_state.get("visit_discoveries", 0) + 1
404
410
  page_state.setdefault("recorded_parts", set()).add(page_state["index"])
405
411
  # A discovery is when the objective can become complete; on a one-answer question the model
@@ -4,7 +4,7 @@ import re
4
4
  from .config import GAP_REFUSAL_LIMIT, LONG_PAGE_UNREAD, MAX_CHECK_DISCOVERY_CHARS, MAX_VISITS_PER_URL, OBJECTIVE_ECHO_SHARE
5
5
  from .prompts import FINAL_RESULT, GIVE_UP_RULE
6
6
  from .text import MISSING_STOPWORDS, shorten
7
- from .pages import determine_url, is_search_page, normalize_query, normalize_url, outline_pointer, read_more_target, search_query, where_you_are
7
+ from .pages import blocked_site, determine_url, is_search_page, normalize_query, normalize_url, outline_pointer, read_more_target, search_query, where_you_are
8
8
  from .grounding import DESCRIBES_PAGE, discovery_lines, grounded_share, grounding_check, is_known_discovery, page_hint
9
9
  from .checklist import check_parts, every_item_has_discoveries, open_items
10
10
 
@@ -187,6 +187,17 @@ def check_decision(action, parameter, journal, tried, page_state, current_url, t
187
187
  elif action == "NAVIGATE":
188
188
  if not parameter.strip():
189
189
  return "BLOCKED_NAVIGATE", "NAVIGATE needs a full URL in the parameter."
190
+ # A list run on Box 1 NAVIGATEd to "discovered", then to sentences ("the size of their budget..."), each opened
191
+ # as https://<text> and failing: four turns lost. Text with spaces or without a dot is no address
192
+ address = parameter.strip().strip("\"'")
193
+ if not re.search(r"https?://\S", address) and not address.startswith("#") and (
194
+ re.search(r"\s", address) or "." not in address):
195
+ return "BLOCKED_NAVIGATE", (f"'{shorten(address, 60)}' is not a URL. NAVIGATE needs a full URL from a link "
196
+ "or a search result (such as https://example.org/page); to look something up, "
197
+ "use SEARCH.")
198
+ if blocked_site(determine_url(action, parameter)):
199
+ return "BLOCKED_NAVIGATE", ("That site is excluded for this run (LAB_BLOCKED_SITES). Find the answer on "
200
+ "another page: SEARCH, or NAVIGATE to a different result.")
190
201
  target = normalize_url(determine_url(action, parameter))
191
202
  # Shown a long page's outline, the model kept trying to jump to a section with a made-up anchor
192
203
  # ("NAVIGATE ...#sqlite-vs-postgresql", then "NAVIGATE #sqlite-vs-postgresql")
@@ -308,8 +319,12 @@ def check_decision(action, parameter, journal, tried, page_state, current_url, t
308
319
  return "BLOCKED_DISCOVER_SNIPPET", ("That DISCOVER is the search result's title and description, not what "
309
320
  "this page says. DISCOVER the facts the page text shown gives for the "
310
321
  "OBJECTIVE, in its own wording, or READ_MORE if they are further on.")
311
- return grounding_check(parameter, page_state, current_url, tried.get("objective", ""))
322
+ return grounding_check(parameter, page_state, current_url, tried.get("objective", ""),
323
+ tried.get("finish_drafts", ()))
312
324
  elif action == "FINISH":
325
+ # Kept whole (the journal keeps only 120 characters of a refused one): a refused FINISH's recommendation
326
+ # came back three turns later inside a DISCOVER, appended to a real page line, and was cited as a finding
327
+ tried.setdefault("finish_drafts", []).append(parameter)
313
328
  return finish_check(tried, journal, page_state, current_url) or gap_check(thought, tried)
314
329
  else:
315
330
  return f"UNKNOWN_{action}", f"'{action}' is not a valid action."
@@ -11,6 +11,10 @@ CONTEXT_LENGTH = int(os.environ.get("LMSTUDIO_CONTEXT_LENGTH", "8192"))
11
11
 
12
12
  SEARCH_URL = "https://html.duckduckgo.com/html/?q="
13
13
 
14
+ # Sites a run must not read, comma-separated (a benchmark run excludes pages that republish its questions and answers):
15
+ # "huggingface.co/datasets,github.com/openai/simple-evals". Empty, so off, unless set
16
+ BLOCKED_SITES = [s.strip() for s in os.environ.get("LAB_BLOCKED_SITES", "").split(",") if s.strip()]
17
+
14
18
  REPORT_FILE = "research_report.md"
15
19
 
16
20
  JOURNAL_BATCH = 6 # The prompt shows 6-11 recent journal entries; old ones are dropped 6 at a time
@@ -16,6 +16,9 @@ AMOUNT_ASKED = re.compile(r"\b(?:quantit(?:y|ies)|amounts?|how (?:much|many|long
16
16
  AMOUNT_GIVEN = re.compile(r"\d|\b(?:one|two|three|four|five|six|seven|eight|nine|ten|twelve|half|quarter|dozen|"
17
17
  r"minutes?|hours?|days?|weeks?|months?|years?|overnight)\b", re.I)
18
18
 
19
+ AMOUNT_GAP = ("no discovery recorded for it gives the amounts it asks for; DISCOVER the words that give them, as the "
20
+ "page writes them")
21
+
19
22
  def amounts_not_found(checklist, journal, skip=()):
20
23
  """The checklist items that ask for amounts while no discovery linked to them gives one, decided in code."""
21
24
  found = [e for e in journal if e.get("action") == "DISCOVER"]
@@ -38,6 +41,16 @@ def quoted_from(quote, text):
38
41
  pairs = word_pairs(" ".join(plain_words(quote)))
39
42
  return bool(pairs) and len(pairs & word_pairs(" ".join(plain_words(text)))) >= 0.8 * len(pairs)
40
43
 
44
+ def names_copied(quote, text, item):
45
+ """Whether a quote of short lines (a name per line: 'GIMP', 'carbohydrates') is copied from the text: each line
46
+ of one or two words is in the text, and at least one of them is not a word of the ITEM itself."""
47
+ lines = [plain_words(line) for line in str(quote).splitlines()]
48
+ lines = [words for words in lines if words]
49
+ if not lines or any(len(words) > 2 for words in lines):
50
+ return False
51
+ known, asked = set(plain_words(text)), set(plain_words(item))
52
+ return all(set(words) <= known for words in lines) and any(set(words) - asked for words in lines)
53
+
41
54
  async def cited_answer(client, item, cited):
42
55
  """Words from the cited discoveries that say what the item asks for, by a yes-or-no question about them alone;
43
56
  '' when the answer is no, the reply is not valid, or its words are not in what was cited."""
@@ -53,7 +66,8 @@ async def cited_answer(client, item, cited):
53
66
  return ""
54
67
  quote = str(reply.get("quote", ""))
55
68
  said = str(reply.get("answer", "")).strip().lower() == "yes"
56
- return quote if said and re.search(r"\w", quote) and any(quoted_from(quote, c) for c in cited) else ""
69
+ return quote if said and re.search(r"\w", quote) and any(
70
+ quoted_from(quote, c) or names_copied(quote, c, item) for c in cited) else ""
57
71
 
58
72
  def item_words(text, checklist=()):
59
73
  """An item's own words: content words (s-stripped) that no other checklist item has."""
@@ -67,6 +81,17 @@ def is_bare_citation(evidence):
67
81
  evidence = str(evidence)
68
82
  return bool(re.search(r"\d", evidence)) and not re.sub(r"\[\d+\]|[\s,;.]|\band\b", "", evidence)
69
83
 
84
+ CONFIRMED_MISSING = ": named by no discovery; DISCOVER it where a page gives it"
85
+
86
+ def concrete_term(part):
87
+ """Whether a missing part is a concrete thing a word check can confirm: at most three words, one of them with a
88
+ digit, an all-capitals abbreviation, or a capital inside the phrase ("O2", "GA3P", "PowerShell activation
89
+ command"), not a description ("the specific steps of the Calvin cycle")."""
90
+ words = part.replace(",", " ").split()
91
+ return 0 < len(words) <= 3 and any(
92
+ re.search(r"\d", w) or re.fullmatch(r"[A-Z][A-Z0-9]+", w) or (i > 0 and re.search(r"[A-Z]", w))
93
+ for i, w in enumerate(words))
94
+
70
95
  def really_missing(missing, found):
71
96
  """The parts of a "missing" verdict that the discoveries do not already contain.
72
97
 
@@ -109,7 +134,7 @@ async def coverage_gaps(client, objective, checklist, journal, only=None, eviden
109
134
  found = [e for e in journal if e.get("action") == "DISCOVER"]
110
135
  listing = "\n\n".join(f"[{n}] {e['extracted_data']}" for n, e in enumerate(found, 1))
111
136
  items = "\n".join(f"{n}. {item['text']}" for n, item in enumerate(checklist, 1))
112
- gaps, unanswered, unproven = [], [], []
137
+ gaps, unanswered, unproven, answered_with = [], [], [], {}
113
138
  for n, item in enumerate(checklist, 1):
114
139
  if OPINION_REQUEST.search(item["text"]) or (only is not None and n not in only):
115
140
  continue
@@ -177,7 +202,15 @@ async def coverage_gaps(client, objective, checklist, journal, only=None, eviden
177
202
  unanswered.append(n)
178
203
  gaps.append(f"Item {n} ({item['text']}): {UNANSWERED_GAP}")
179
204
  elif missing:
180
- gaps.append(f"Item {n} ({item['text']}): missing {missing}")
205
+ # A concrete thing ("O2", "GA3P", "Step 3") the check says is missing, and no discovery names, is confirmed
206
+ # by the code, so it sends the agent back: an explanation finished in 4 turns from one overview paragraph,
207
+ # without oxygen, while the check said "missing O2". Vague verdicts ("the specific steps") stay soft: about
208
+ # half were wrong. Of 117 saved "missing" verdicts, 2 were concrete (this one and "Step 3")
209
+ concrete = [p for p in missing.split(", ") if concrete_term(p)]
210
+ gaps.append(f"Item {n} ({item['text']}): missing {missing}"
211
+ + (f" ({', '.join(concrete)}{CONFIRMED_MISSING})" if concrete else ""))
212
+ if n not in unanswered:
213
+ answered_with[n] = re.sub(r"\[\d+\]", " ", str(verdict.get("evidence", "")))
181
214
  # Words the check copied for an answered item, kept where they really are in the discoveries: the final
182
215
  # result is shown them, as it said "the discoveries do not specify what goes into photosynthesis" over a
183
216
  # discovery that gave water, sunlight and carbon dioxide
@@ -210,8 +243,63 @@ async def coverage_gaps(client, objective, checklist, journal, only=None, eviden
210
243
  unproven.append((str(thing["name"]).strip(), condition))
211
244
  gaps.append(f"Item {n}: {thing['name']}: no words in DISCOVERIES SO FAR show it is {condition}. SEARCH "
212
245
  f"for '{thing['name']} {condition}' to check it, or replace it with one that is")
246
+ for n in shared_evidence_items(answered_with, checklist):
247
+ unanswered.append(n)
248
+ gaps.append(shared_gap(n, checklist[n - 1]["text"]))
213
249
  return gaps, unanswered, unproven
214
250
 
251
+ def shared_gap(n, text):
252
+ """The gap for an item whose only evidence was another item's words. It ends with UNANSWERED_GAP, the mark of a
253
+ gap that sends the agent back: with the reason after it, v9 found such items on Box 1's comparison but did not
254
+ send the agent back, and the final result filled all three items from memory."""
255
+ return (f"Item {n} ({text}): the words given for it are the same as for another item and say nothing of its own, "
256
+ f"so {UNANSWERED_GAP}")
257
+
258
+ # Units as pages abbreviate them after a number, by the word an item asks with (in item_words' s-stripped form)
259
+ UNIT_WORDS = {"m": "meter", "metres": "metre", "km": "kilometer", "cm": "centimeter", "mm": "millimeter",
260
+ "ft": "feet", "mi": "mile", "in": "inche", "kg": "kilogram", "g": "gram", "lb": "pound", "lbs": "pound",
261
+ "oz": "ounce", "l": "liter", "ml": "milliliter", "min": "minute", "h": "hour", "hr": "hour",
262
+ "hrs": "hour", "c": "celsiu", "f": "fahrenheit"}
263
+ UNIT_AFTER_NUMBER = re.compile(r"\d\s*(" + "|".join(sorted(UNIT_WORDS, key=len, reverse=True)) + r")\b", re.I)
264
+
265
+ def units_written(text):
266
+ """The unit words a text gives by abbreviation after a number: "5,895 m (19,341 ft)" gives meter and feet. A
267
+ Kilimanjaro sentence answering both "in meters" and "in feet" was dropped for both, as if it said neither."""
268
+ found = {UNIT_WORDS[m.group(1).lower()] for m in UNIT_AFTER_NUMBER.finditer(str(text))}
269
+ return found | ({"foot"} if "feet" in found else set())
270
+
271
+ def shared_evidence_items(answered_with, checklist):
272
+ """Items whose only evidence is the same words given for other items, none of its own words in them.
273
+
274
+ A comparison finished from one sentence ("I'd pick SQLite for any single-server app under roughly 10,000 daily
275
+ active users, and PostgreSQL the moment concurrent writes ... enter the picture"), given as the evidence for
276
+ setup, many users and features alike, and pasted under all three. Of items sharing the same words, those whose
277
+ own words appear in them keep them; the others count as unanswered. Items about the same thing ("Compare SQLite
278
+ and PostgreSQL features", "Name three features PostgreSQL has") may share words: they share a word of their own,
279
+ one that no more than half of the checklist items have ("compare", "sqlite" and "postgresql" in 3 of 4 do not
280
+ count)."""
281
+ def words_of(text):
282
+ return {w.rstrip("s") for w in plain_words(text) if len(w) > 2} - MISSING_STOPWORDS - ITEM_VERBS
283
+ texts = {n: words_of(item["text"]) for n, item in enumerate(checklist, 1)}
284
+ common = {w for w in set().union(*texts.values()) if sum(w in t for t in texts.values()) > len(texts) / 2}
285
+ dropped = set()
286
+ for n, words in answered_with.items():
287
+ same = [m for m, other in answered_with.items()
288
+ if m != n and quoted_from(words, other) and quoted_from(other, words)]
289
+ if not same or any((texts[n] & texts[m]) - common for m in same):
290
+ continue
291
+ given = item_words(words) | units_written(words)
292
+ overlap = {m: len(item_words(checklist[m - 1]["text"], checklist) & given) for m in [n] + same}
293
+ if overlap[n] == 0 or overlap[n] < max(overlap.values()):
294
+ dropped.add(n)
295
+ return sorted(dropped)
296
+
297
+ SHORT_COUNT = re.compile(r"^Item (\d+) \(.*\): only (\d+) of (\d+) found \(")
298
+
299
+ def short_counts(gaps):
300
+ """{item number: (things found, things asked for)} from the coverage gaps."""
301
+ return {int(m.group(1)): (int(m.group(2)), int(m.group(3))) for m in map(SHORT_COUNT.match, gaps) if m}
302
+
215
303
  async def coverage_refusal(client, objective, tried, journal):
216
304
  # Once per run, so a noisy verdict costs one turn at most and a gap that cannot be filled never blocks finishing
217
305
  checklist = tried.get("checklist")
@@ -222,9 +310,15 @@ async def coverage_refusal(client, objective, tried, journal):
222
310
  print("🔎 Checking each checklist item against the discoveries before finishing...")
223
311
  gaps, tried["unanswered"], unproven = await coverage_gaps(client, objective, checklist, journal,
224
312
  evidence=tried.setdefault("evidence", {}))
313
+ tried["short_counts"] = short_counts(gaps)
225
314
  # Items whose named things lacked proof of a condition are checked again before the final result
226
315
  tried["unproven_items"] = sorted({n for n, item in enumerate(checklist, 1) if any(
227
316
  g.startswith(f"Item {n}: ") and "no words in DISCOVERIES SO FAR show it is" in g for g in gaps)})
317
+ # Amounts asked for and given by no discovery of the item, decided in code: a cold brew run on Box 1 had the
318
+ # check's "missing quantities" (not sent back), its own FINISH drafts gave "1 cup (113 grams)", and the
319
+ # final result said the amounts were not found, because nothing made it record the card that gave them
320
+ gaps += [f"Item {n} ({checklist[n - 1]['text']}): {AMOUNT_GAP}"
321
+ for n in amounts_not_found(checklist, journal, skip=set(tried["unanswered"]))]
228
322
  else:
229
323
  # A factual run finished with nothing recorded (its answer was refused as not on the page shown) and its
230
324
  # final result said nothing was found
@@ -235,7 +329,8 @@ async def coverage_refusal(client, objective, tried, journal):
235
329
  # four suite runs ("missing official documentation" on a comparison, "H2O, O2" already recorded, "free" on a
236
330
  # correct list of open-source suites) and cost a finish each; those verdicts now only shape the final result
237
331
  hard = [g for g in gaps if g.endswith(UNANSWERED_GAP) or re.search(r"\): only \d+ of \d+ found", g)
238
- or g.startswith("Nothing has been recorded")]
332
+ or g.startswith("Nothing has been recorded") or g.endswith(AMOUNT_GAP)
333
+ or g.endswith(CONFIRMED_MISSING + ")")]
239
334
  if gaps:
240
335
  print("⚠️ Gaps found:\n" + "\n".join(f" - {g}{'' if g in hard else ' (not sent back)'}" for g in gaps))
241
336
  gaps = hard
@@ -264,10 +359,14 @@ async def not_found_items(client, objective, tried, journal):
264
359
  return [], []
265
360
  candidates = sorted({n for n, item in enumerate(checklist, 1) if not item["discoveries"]
266
361
  and not OPINION_REQUEST.search(item["text"])} | set(tried.get("unanswered") or []))
267
- rechecked = sorted(set(candidates) | set(tried.get("unproven_items") or []))
362
+ # Items short of the number they ask for are counted again too: what the agent recorded after being sent back
363
+ # may complete them
364
+ short = tried.get("short_counts") or {}
365
+ rechecked = sorted(set(candidates) | set(tried.get("unproven_items") or []) | set(short))
268
366
  if not rechecked or not any(e.get("action") == "DISCOVER" for e in journal):
269
367
  return candidates, []
270
368
  print(f"🔎 Checking checklist item(s) {', '.join(map(str, rechecked))} once more before writing the final result...")
271
- _, unanswered, unproven = await coverage_gaps(client, objective, checklist, journal, only=set(rechecked),
272
- evidence=tried.setdefault("evidence", {}))
369
+ gaps, unanswered, unproven = await coverage_gaps(client, objective, checklist, journal, only=set(rechecked),
370
+ evidence=tried.setdefault("evidence", {}))
371
+ tried["short_counts"] = {n: count for n, count in short_counts(gaps).items() if n not in unanswered}
273
372
  return [n for n in unanswered if n in candidates or n in rechecked], unproven
@@ -3,7 +3,7 @@ names never mentioned, quoted when one is refused."""
3
3
  import re
4
4
  import unicodedata
5
5
  from .config import GROUNDED_PAIR_SHARE, GROUNDED_SENTENCE_SHARE, KNOWN_LINE_THRESHOLD, MAX_CLOSEST_CHARS, MIN_GROUNDING_PAIRS, MIN_SENTENCE_PAIRS, UNGROUNDED_PAIR_SHARE, UNGROUNDED_SENTENCE_SHARE
6
- from .text import plain_words, shorten, word_pairs
6
+ from .text import plain_words, shorten, with_digits, word_pairs
7
7
  from .pages import normalize_url
8
8
 
9
9
  QUOTE_STYLES = str.maketrans({"‘": "'", "’": "'", "“": "'", "”": "'", '"': "'", "`": "'"})
@@ -24,8 +24,9 @@ def discovery_lines(text):
24
24
 
25
25
  def grounded_share(text, parts):
26
26
  """The share of a discovery's consecutive word pairs that also appear in the given page parts."""
27
- mine = word_pairs(str(text).lower())
28
- return len(mine & word_pairs(" ".join(parts).lower())) / len(mine) if mine else 1.0
27
+ # "1 cup" and the page's "one cup" are the same words
28
+ mine = word_pairs(with_digits(text).lower())
29
+ return len(mine & word_pairs(with_digits(" ".join(parts)).lower())) / len(mine) if mine else 1.0
29
30
 
30
31
  def code_text(text):
31
32
  # Case, spacing, quote style and placeholder brackets ("<venv>/bin/activate") do not make code different
@@ -77,7 +78,31 @@ def command_lines(commands, parts):
77
78
  heads = [h for h in heads if len(h) > 3]
78
79
  return [shorten(line, MAX_CLOSEST_CHARS) for line in dict.fromkeys(x for x in lines if any(h in x.lower() for h in heads))][:2]
79
80
 
80
- def grounding_check(parameter, page_state, current_url, objective=""):
81
+ OWN_DRAFT_SHARE = 0.6 # A DISCOVER sentence sharing this much of its word pairs with an earlier FINISH draft...
82
+ OWN_PAGE_SHARE = 0.35 # ...and less than this with the page itself (without the objective) is the agent's own text
83
+
84
+
85
+ def own_draft_sentence(parameter, page_parts, drafts):
86
+ """A sentence of the DISCOVER the agent wrote itself in an earlier FINISH, and the page does not give.
87
+
88
+ Its FINISH "For a hobby project with a few hundred users, PostgreSQL would be the recommended choice..." was
89
+ refused; three turns later that sentence came back appended to a real page line, as a DISCOVER, and the result
90
+ cited it for its recommendation. It scored as grounded because the objective counts as page text, and the
91
+ sentence is mostly the objective's own words; scored against the page alone, it is not there."""
92
+ if not drafts:
93
+ return None
94
+ mine = [word_pairs(" ".join(plain_words(d))) for d in drafts]
95
+ for s in re.split(r"(?<=[.!?])\s+|\n+", parameter):
96
+ pairs = word_pairs(" ".join(plain_words(s)))
97
+ if len(pairs) < MIN_SENTENCE_PAIRS:
98
+ continue
99
+ from_draft = any(len(pairs & d) >= OWN_DRAFT_SHARE * len(pairs) for d in mine)
100
+ if from_draft and grounded_share(s, page_parts) < OWN_PAGE_SHARE:
101
+ return s.strip()
102
+ return None
103
+
104
+
105
+ def grounding_check(parameter, page_state, current_url, objective="", drafts=()):
81
106
  # Shown part 2 of a guide, which ended "Just follow these 6 easy steps", the model recorded six steps it
82
107
  # wrote itself (the real ones were in part 3), then a care routine the page never gave. Real discoveries
83
108
  # from earlier runs shared 64-100% of their word pairs with the page; these shared 11-44%, and a rerun's
@@ -100,6 +125,13 @@ def grounding_check(parameter, page_state, current_url, objective=""):
100
125
  f"write it. Commands are copied exactly as the page writes them{said}. "
101
126
  "DISCOVER that in the page's own words, or READ_MORE if a later part gives "
102
127
  "the command.")
128
+ # Refused outright, before the share checks: repeating it must not confirm it, since the agent wrote it
129
+ own = own_draft_sentence(parameter, shown[:-1], drafts)
130
+ if own:
131
+ return "BLOCKED_DISCOVER_OWN_WORDS", (f"\"{shorten(own, 160)}\" is what you wrote yourself when you tried to "
132
+ "FINISH, not what the page says. DISCOVER only the page's own words; your "
133
+ "conclusion or recommendation belongs in the final result, written at "
134
+ "FINISH.")
103
135
  long_enough = len(word_pairs(parameter)) >= MIN_GROUNDING_PAIRS
104
136
  whole = grounded_share(parameter, shown) if long_enough else 1.0
105
137
  # An invented sentence can hide behind copied ones: an opinion task's discovery was 75% copied, but its last