localagentbrowser 1.41__py3-none-any.whl

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.
agent_browser.py ADDED
@@ -0,0 +1,1107 @@
1
+ """LocalAgentBrowser: an autonomous web agent driven by a local language model.
2
+
3
+ A model served by LM Studio decides, one JSON action at a time, whether to SEARCH, NAVIGATE, READ_MORE,
4
+ DISCOVER or FINISH; Playwright carries the actions out in a visible Chromium window. Everything the
5
+ agent does and finds is written to research_log.txt.
6
+
7
+ Run: python agent_browser.py (or `localagentbrowser` once installed; it asks for the objective)
8
+
9
+ Settings (environment variables):
10
+ LMSTUDIO_BASE_URL OpenAI-compatible server address (default http://localhost:1234/v1)
11
+ LMSTUDIO_MODEL model identifier (default qwen2.5-7b-instruct)
12
+ LMSTUDIO_API_KEY API key, if the server needs one (default lm-studio)
13
+ LMSTUDIO_CONTEXT_LENGTH context length the model is loaded with (default 8192)
14
+ """
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import urllib.parse
19
+ import re
20
+ import unicodedata
21
+ from openai import AsyncOpenAI
22
+ from playwright.async_api import async_playwright
23
+
24
+ BASE_URL = os.environ.get("LMSTUDIO_BASE_URL", "http://localhost:1234/v1")
25
+ MODEL = os.environ.get("LMSTUDIO_MODEL", "qwen2.5-7b-instruct")
26
+ API_KEY = os.environ.get("LMSTUDIO_API_KEY", "lm-studio")
27
+ CONTEXT_LENGTH = int(os.environ.get("LMSTUDIO_CONTEXT_LENGTH", "8192"))
28
+
29
+ SEARCH_URL = "https://html.duckduckgo.com/html/?q="
30
+ REPORT_FILE = "research_report.md"
31
+ JOURNAL_BATCH = 6 # The prompt shows 6-11 recent journal entries; old ones are dropped 6 at a time
32
+ MAX_JOURNAL_DETAIL_CHARS = 120 # Longest query/URL/request text shown per journal line
33
+ MAX_THOUGHT_CHARS = 120 # Old reasoning is trimmed in the prompt; the full text stays in research_log.txt
34
+ MAX_SEARCH_RESULTS = 8
35
+ MAX_SNIPPET_CHARS = 200
36
+ PAGE_PART_CHARS = 2500 # Pages are shown in parts of about this size; READ_MORE shows the next part
37
+ MAX_LINKS = 15
38
+ OUTLINE_MIN_PARTS = 5 # Pages with at least this many parts get an outline of what each part covers
39
+ MAX_OUTLINE_LINES = 40
40
+ MAX_OUTLINE_LABEL = 90
41
+ LONG_PAGE_UNREAD = 3 # Above this many unread parts, the leave check points to the outline instead of the count
42
+ MAX_JSON_RETRIES = 3
43
+ MAX_VISITS_PER_URL = 3 # Returning to an earlier page is allowed; this caps back-and-forth loops
44
+ GAP_REFUSAL_LIMIT = 2 # FINISH attempts sent back per run because their own reasoning lists a part as not covered
45
+ MAX_CHEAP_RETRIES = 2 # Refused replies re-asked within the same turn before the refusal costs a whole turn
46
+ MAX_FOLLOWUPS = 12 # Turns that continue one conversation before a fresh full prompt (the token budget usually ends it first)
47
+ MAX_CONVERSATION_TOKENS = int(CONTEXT_LENGTH * 0.75) # Start fresh with room left for the next page and the reply
48
+ MAX_DISCOVERY_PROMPT_CHARS = 250 # Discoveries are shortened to this in fresh prompts; the log keeps the full text
49
+ TURN_PAUSE_SECONDS = 1
50
+ STALL_TURNS = 4 # Turns without a new discovery before the model is asked to check its progress
51
+ COMPOSE_MAX_TOKENS = 900 # Length limit for the separately written final result
52
+ OBJECTIVE_CHECK_EVERY = 8 # Turns between scheduled objective checks, which happen even while discoveries continue
53
+ READ_FIRST = ("First, if the CURRENT ENVIRONMENT (or an unread part of this page, via READ_MORE) contains information "
54
+ "relevant to the objective that is not yet in DISCOVERIES SO FAR, DISCOVER it now.")
55
+ CHECK_PARTS = ("split the OBJECTIVE into its separate parts and, in thought_process, write each part followed by the "
56
+ "discovery that covers it, or 'not covered'. Only parts that ask for your own answer, opinion, recommendation "
57
+ "or summary are 'written at FINISH' (write them in the FINISH parameter, never as a DISCOVER); information "
58
+ "the objective asks you to find is never 'written at FINISH': it is covered by a discovery or 'not covered'.")
59
+ FINAL_RESULT = ("the complete final result the objective asks for (for example the answer, opinion, summary or "
60
+ "deliverable, not just a list of what you found)")
61
+ GIVE_UP_RULE = ("If you have already looked for a part in several places without finding it, it may not be available: "
62
+ "finish anyway and say in the final result what could not be found.")
63
+ FINISH_CHECK = (f"Before finishing, {CHECK_PARTS} If any part is 'not covered', do not finish: choose the action that "
64
+ f"works on that part ({GIVE_UP_RULE}) If every other part is covered, repeat FINISH with {FINAL_RESULT} "
65
+ "in the parameter.")
66
+
67
+ SYSTEM_PROMPT = """You are an autonomous web discovery agent. Your job is to achieve the high-level OBJECTIVE by executing sequential actions.
68
+ On every turn, evaluate the CURRENT ENVIRONMENT and your RESEARCH JOURNAL. You must output exactly ONE valid JSON block matching this structure:
69
+
70
+ {
71
+ "thought_process": "Your active reasoning about what to discover next and why.",
72
+ "action": "SEARCH" | "NAVIGATE" | "READ_MORE" | "DISCOVER" | "FINISH",
73
+ "parameter": "The search query string, the full target URL, a page part number, the extracted summary data, or an empty string"
74
+ }
75
+
76
+ Action guide:
77
+ - SEARCH: parameter is a plain-text query (not a URL). Results appear as a numbered list with URLs.
78
+ - NAVIGATE: parameter is a full URL copied exactly from the search results or page links you were shown.
79
+ - READ_MORE: long pages are shown in parts. Use an empty parameter to see the next part, or a part number to jump to that part.
80
+ - DISCOVER: parameter is the information you found on the current page that serves the objective. DISCOVER does not extract anything for you: you must write the information itself into the parameter. An empty DISCOVER is refused. Copy exact text where precision matters. You may group several related details from the same page into one DISCOVER. Only information found on the page belongs in a DISCOVER; your own conclusions and opinions go in the FINISH parameter.
81
+ - FINISH: only when your discoveries cover every part of the objective. The parameter must contain the complete final result the objective asks for (for example the answer, opinion, summary or deliverable), not just a list of what you found.
82
+
83
+ Strictly follow these rules:
84
+ 1. If the current page contains information relevant to the objective that is not already in DISCOVERIES SO FAR, your action MUST be DISCOVER before you leave the page.
85
+ 2. Never repeat a search query, and never NAVIGATE to the page you are already on. You may return to a previously visited URL when it still has information you need, at most 3 visits per URL. These limits are enforced automatically.
86
+ 3. Prefer primary, authoritative sources over copies and summaries of them.
87
+ 4. Gather only what the objective asks for. As soon as your discoveries cover every part of it, FINISH; do not keep reading for extra or merely interesting details.
88
+ 5. Output ONLY raw JSON. No markdown wrappers, no backticks, no conversational filler."""
89
+
90
+ RESPONSE_FORMAT = {
91
+ "type": "json_schema",
92
+ "json_schema": {
93
+ "name": "agent_action",
94
+ "strict": True,
95
+ "schema": {
96
+ "type": "object",
97
+ "properties": {
98
+ "thought_process": {"type": "string"},
99
+ "action": {"type": "string", "enum": ["SEARCH", "NAVIGATE", "READ_MORE", "DISCOVER", "FINISH"]},
100
+ "parameter": {"type": "string"}
101
+ },
102
+ "required": ["thought_process", "action", "parameter"]
103
+ }
104
+ }
105
+ }
106
+
107
+ BLOCK_START = "[[[BLOCK_START {}]]]"
108
+ BLOCK_END = "[[[BLOCK_END]]]"
109
+ BLOCK_PATTERN = re.compile(r'\[\[\[BLOCK_START (\w+)\]\]\](.*?)\[\[\[BLOCK_END\]\]\]', re.S)
110
+ HEADING_MARK = "[[[H]]]"
111
+
112
+ # Hides navigation menus, footers and cookie/consent banners, wraps code blocks and tables in markers
113
+ # so they are never split, marks headings for the page outline, reads the visible text, then restores
114
+ # the page exactly as it was.
115
+ PAGE_TEXT_JS = r"""([blockStart, blockEnd, headingMark]) => {
116
+ const clutter = new Set();
117
+ document.querySelectorAll('nav, footer, [role=navigation], [role=contentinfo]').forEach(el => clutter.add(el));
118
+ document.querySelectorAll('#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, .qc-cmp2-container, .fc-consent-root, #truste-consent-track').forEach(el => clutter.add(el));
119
+ const consentName = /cookie|consent|gdpr/i;
120
+ document.querySelectorAll('body *').forEach(el => {
121
+ const name = (el.id || '') + ' ' + (typeof el.className === 'string' ? el.className : '');
122
+ if (!consentName.test(name)) return;
123
+ const pos = getComputedStyle(el).position;
124
+ if (pos === 'fixed' || pos === 'sticky' || el.getAttribute('role') === 'dialog' || el.getAttribute('aria-modal') === 'true') clutter.add(el);
125
+ });
126
+
127
+ const fullText = document.body ? document.body.innerText : '';
128
+ const hidden = [];
129
+ const markers = [];
130
+ try {
131
+ clutter.forEach(el => {
132
+ hidden.push([el, el.style.getPropertyValue('display'), el.style.getPropertyPriority('display')]);
133
+ el.style.setProperty('display', 'none', 'important');
134
+ });
135
+ document.querySelectorAll('pre, table').forEach(el => {
136
+ if (el.parentElement && el.parentElement.closest('pre, table')) return; // Only mark the outermost block
137
+ const start = document.createTextNode(blockStart.replace('{}', el.tagName.toLowerCase()));
138
+ const end = document.createTextNode(blockEnd);
139
+ el.parentNode.insertBefore(start, el);
140
+ el.parentNode.insertBefore(end, el.nextSibling);
141
+ markers.push(start, end);
142
+ });
143
+ document.querySelectorAll('h1, h2, h3, h4').forEach(el => {
144
+ if (el.closest('pre, table')) return;
145
+ const mark = document.createTextNode(headingMark);
146
+ el.insertBefore(mark, el.firstChild);
147
+ markers.push(mark);
148
+ });
149
+ let text = document.body ? document.body.innerText : '';
150
+ // Safety net: if hiding clutter removed most of the page, the "clutter" was the content
151
+ if (text.length < fullText.length * 0.2) {
152
+ hidden.forEach(([el, value, priority]) => el.style.setProperty('display', value, priority));
153
+ hidden.length = 0;
154
+ text = document.body.innerText;
155
+ }
156
+ return text;
157
+ } finally {
158
+ markers.forEach(node => node.remove());
159
+ hidden.forEach(([el, value, priority]) => {
160
+ if (value) el.style.setProperty('display', value, priority); else el.style.removeProperty('display');
161
+ });
162
+ }
163
+ }"""
164
+
165
+ def normalize_url(url):
166
+ return url.split("#")[0].rstrip("/") if url else url
167
+
168
+ def normalize_query(query):
169
+ return " ".join(str(query).lower().split())
170
+
171
+ def determine_url(action, parameter):
172
+ if isinstance(parameter, list):
173
+ clean_param = str(parameter[0]) if len(parameter) > 0 else ""
174
+ else:
175
+ clean_param = str(parameter)
176
+
177
+ clean_param = clean_param.strip().strip('"').strip("'")
178
+
179
+ if action == "NAVIGATE":
180
+ if "#" in clean_param:
181
+ clean_param = clean_param.split("#")[0]
182
+
183
+ if clean_param.startswith(('http://', 'https://')):
184
+ return clean_param
185
+ return f"https://{clean_param}"
186
+ elif action == "SEARCH":
187
+ return f"{SEARCH_URL}{urllib.parse.quote_plus(search_query(clean_param))}"
188
+ return None
189
+
190
+ def search_query(parameter):
191
+ # If the model hands back a full search engine URL, pull the query back out of it
192
+ clean_param = str(parameter).strip().strip('"').strip("'")
193
+ if clean_param.startswith(('http://', 'https://')) and ("duckduckgo.com" in clean_param or "google.com/search" in clean_param):
194
+ query_dict = urllib.parse.parse_qs(urllib.parse.urlparse(clean_param).query)
195
+ if 'q' in query_dict:
196
+ clean_param = query_dict['q'][0]
197
+ return clean_param.replace('+', ' ')
198
+
199
+ def decode_ddg_link(href):
200
+ # DuckDuckGo wraps result links as //duckduckgo.com/l/?uddg=<encoded-target>&rut=...
201
+ if not href:
202
+ return ""
203
+ full = urllib.parse.urljoin("https://duckduckgo.com", href)
204
+ parsed = urllib.parse.urlparse(full)
205
+ if parsed.netloc.endswith("duckduckgo.com") and parsed.path.startswith("/l/"):
206
+ target = urllib.parse.parse_qs(parsed.query).get("uddg")
207
+ if target:
208
+ return target[0]
209
+ return full
210
+
211
+ async def count_search_results(page):
212
+ try:
213
+ return await page.locator(".result:not(.result--ad) a.result__a").count()
214
+ except Exception:
215
+ return 0
216
+
217
+ async def extract_search_results(page, visited_urls):
218
+ results = await page.evaluate("""() => Array.from(document.querySelectorAll('.result:not(.result--ad)')).map(r => {
219
+ const a = r.querySelector('a.result__a');
220
+ const s = r.querySelector('.result__snippet');
221
+ return a ? {title: a.innerText.trim(), href: a.getAttribute('href'), snippet: s ? s.innerText.trim() : ''} : null;
222
+ }).filter(Boolean)""")
223
+
224
+ lines = []
225
+ for i, r in enumerate(results[:MAX_SEARCH_RESULTS], start=1):
226
+ url = decode_ddg_link(r['href'])
227
+ visited = " (ALREADY VISITED)" if normalize_url(url) in visited_urls else ""
228
+ snippet = r['snippet'][:MAX_SNIPPET_CHARS]
229
+ lines.append(f"[{i}] {r['title']}{visited}\n URL: {url}\n {snippet}")
230
+ return "\n\n".join(lines)
231
+
232
+ def text_units(text):
233
+ """Split page text into units that must stay whole: code blocks, tables, and paragraphs."""
234
+ units = []
235
+ def paragraphs(chunk):
236
+ return [p.strip() for p in re.split(r'\n\s*\n', chunk) if p.strip()]
237
+
238
+ pos = 0
239
+ for m in BLOCK_PATTERN.finditer(text):
240
+ units += paragraphs(text[pos:m.start()])
241
+ body = m.group(2).strip("\n")
242
+ if body.strip():
243
+ units.append(f"```\n{body}\n```" if m.group(1) == "pre" else body)
244
+ pos = m.end()
245
+ units += paragraphs(text[pos:])
246
+ return units
247
+
248
+ def split_oversized(unit, limit):
249
+ """Last resort for a single unit bigger than a whole part: split at line, then sentence, boundaries."""
250
+ if len(unit) <= limit:
251
+ return [unit]
252
+ fenced = unit.startswith("```\n") and unit.endswith("\n```")
253
+ body = unit[4:-4] if fenced else unit
254
+ budget = limit - 40 if fenced else limit
255
+
256
+ pieces, current = [], ""
257
+ lines = body.split("\n") if "\n" in body else re.split(r'(?<=[.!?])\s+', body)
258
+ joiner = "\n" if "\n" in body else " "
259
+ for line in lines:
260
+ while len(line) > budget: # A single line longer than a part: hard cut is unavoidable
261
+ if current:
262
+ pieces.append(current)
263
+ current = ""
264
+ pieces.append(line[:budget])
265
+ line = line[budget:]
266
+ if current and len(current) + len(joiner) + len(line) > budget:
267
+ pieces.append(current)
268
+ current = line
269
+ else:
270
+ current = f"{current}{joiner}{line}" if current else line
271
+ if current:
272
+ pieces.append(current)
273
+
274
+ if fenced:
275
+ return [f"```\n{p}\n```" + ("\n(code block continues in next part)" if i < len(pieces) - 1 else "") for i, p in enumerate(pieces)]
276
+ return pieces
277
+
278
+ def split_into_parts(text, limit=PAGE_PART_CHARS):
279
+ parts, current = [], ""
280
+ for unit in text_units(text):
281
+ for piece in split_oversized(unit, limit):
282
+ if current and len(current) + 2 + len(piece) > limit:
283
+ parts.append(current)
284
+ current = piece
285
+ else:
286
+ current = f"{current}\n\n{piece}" if current else piece
287
+ if current:
288
+ parts.append(current)
289
+ return parts
290
+
291
+ async def extract_page_parts(page):
292
+ """Returns the page split into parts, and an outline label for each part."""
293
+ text = await page.evaluate(PAGE_TEXT_JS, [BLOCK_START, BLOCK_END, HEADING_MARK])
294
+ return build_outline(split_into_parts(text or ""))
295
+
296
+ def build_outline(parts):
297
+ """Strips the heading markers and labels each part with its headings (or its first line if it has none)."""
298
+ clean, outline = [], []
299
+ for part in parts:
300
+ headings = [" ".join(h.split()) for h in re.findall(re.escape(HEADING_MARK) + r"([^\n]*)", part) if h.strip()]
301
+ text = part.replace(HEADING_MARK, "")
302
+ if headings:
303
+ label = " / ".join(headings)
304
+ else:
305
+ # First line with real text, skipping code-fence lines (```)
306
+ label = next((line.strip() for line in text.splitlines() if line.strip().strip("`").strip()), "(no text)")
307
+ clean.append(text)
308
+ outline.append(label if len(label) <= MAX_OUTLINE_LABEL else label[:MAX_OUTLINE_LABEL] + "...")
309
+ return clean, outline
310
+
311
+ async def extract_page_links(page, shown_links=None):
312
+ # Links come from the whole page, menus included, since navigation menus are how sites are used
313
+ # a.href resolves relative links (e.g. /docs/intro) to absolute URLs
314
+ # shown_links holds links already listed earlier in the running conversation; they are skipped so a
315
+ # new page on the same site lists its own links instead of repeating the site menu
316
+ links = await page.evaluate("""() => Array.from(document.querySelectorAll('a[href]'))
317
+ .map(a => ({text: a.innerText.trim().replace(/\\s+/g, ' '), href: a.href.split('#')[0]}))
318
+ .filter(l => l.href.startsWith('http') && l.text.length >= 4)""")
319
+ seen = set()
320
+ link_lines = []
321
+ omitted = 0
322
+ for link in links:
323
+ if link["href"] in seen or normalize_url(link["href"]) == normalize_url(page.url):
324
+ continue
325
+ seen.add(link["href"])
326
+ if shown_links is not None and link["href"] in shown_links:
327
+ omitted += 1
328
+ continue
329
+ if len(link_lines) < MAX_LINKS:
330
+ link_lines.append(f"- {link['text'][:60]} -> {link['href']}")
331
+ if shown_links is not None:
332
+ shown_links.add(link["href"])
333
+ if omitted:
334
+ link_lines.append(f"({omitted} links already listed earlier in this conversation are not repeated)")
335
+ return "\n".join(link_lines)
336
+
337
+ async def build_environment(page, current_url, page_state, visited_urls=frozenset(), shown_links=None):
338
+ if not current_url:
339
+ return "[Empty or No Target Page Loaded Yet]"
340
+ try:
341
+ if "duckduckgo.com" in current_url:
342
+ results = await extract_search_results(page, visited_urls)
343
+ if results:
344
+ return f"Search Results:\n{results}"
345
+ parts, _ = await extract_page_parts(page)
346
+ return f"[No search results parsed - possibly no matches or a search engine error]\n{parts[0][:1500] if parts else ''}"
347
+
348
+ # Parts are cached per URL so part numbers stay stable while the model reads through a page
349
+ if page_state["url"] != normalize_url(current_url):
350
+ parts, outline = await extract_page_parts(page)
351
+ page_state.update(url=normalize_url(current_url), parts=parts, outline=outline, index=0,
352
+ seen_parts={0}, visit_discoveries=0, leave_confirmed=False, reread_confirmed=set())
353
+ link_lines = await extract_page_links(page, shown_links)
354
+ return f"{render_part(page_state, with_outline=True)}\n\nLinks On Page:\n{link_lines or '[None]'}"
355
+ except Exception as e:
356
+ return f"[Error reading page content: {e}]"
357
+
358
+ def render_part(page_state, with_outline=False):
359
+ parts, index = page_state["parts"], page_state["index"]
360
+ if not parts:
361
+ return "Page Text:\n[No readable text]"
362
+ if index < len(parts) - 1:
363
+ footer = f"[Page continues: READ_MORE shows part {index + 2} of {len(parts)}]"
364
+ else:
365
+ footer = "[End of page]"
366
+ text = f"Page Text (part {index + 1} of {len(parts)}):\n{parts[index]}\n{footer}"
367
+ if with_outline:
368
+ text = f"{render_outline(page_state)}{text}"
369
+ return text
370
+
371
+ def render_outline(page_state):
372
+ # On a long page the model read part after part in order (part 4 of 33, nothing new) because it could
373
+ # not see where anything was; the outline lets it jump to the part it needs, like skimming headings
374
+ outline = page_state.get("outline") or []
375
+ if len(outline) < OUTLINE_MIN_PARTS:
376
+ return ""
377
+ # Read parts are marked: after a fresh prompt the model no longer sees its earlier reading and went back
378
+ # to re-read parts it had read half an hour before
379
+ seen = page_state.get("seen_parts") or set()
380
+ lines = [f" {i}. {label}{' (read)' if i - 1 in seen else ''}"
381
+ for i, label in enumerate(outline[:MAX_OUTLINE_LINES], start=1)]
382
+ if len(outline) > MAX_OUTLINE_LINES:
383
+ lines.append(f" ... ({len(outline) - MAX_OUTLINE_LINES} more parts)")
384
+ return (f"Page Outline ({len(outline)} parts; READ_MORE with a part number jumps straight to that part):\n"
385
+ + "\n".join(lines) + "\n\n")
386
+
387
+ QUOTE_STYLES = str.maketrans({"‘": "'", "’": "'", "“": "'", "”": "'", '"': "'", "`": "'"})
388
+
389
+ KNOWN_LINE_THRESHOLD = 0.9
390
+
391
+ def discovery_lines(text):
392
+ # Case, spacing and quote style (curly, straight, single, double) do not make a line new
393
+ text = unicodedata.normalize("NFKC", str(text)).translate(QUOTE_STYLES)
394
+ return {" ".join(line.lower().split()) for line in text.splitlines() if line.strip()}
395
+
396
+ def word_pairs(text):
397
+ words = re.findall(r"\w+", text)
398
+ return {tuple(words[i:i + 2]) for i in range(len(words) - 1)} or ({tuple(words)} if words else set())
399
+
400
+ def is_known_discovery(parameter, journal):
401
+ # Compared by consecutive word pairs, so line breaks, commas, punctuation and the order of lines do not
402
+ # make old information look new. A line is known when 90% of its word pairs were already recorded;
403
+ # the discovery is a repeat only when every line is known, so one genuinely new line keeps it.
404
+ # A long line (8+ words) made entirely of recorded words is also known: it catches a block rejoined
405
+ # after its recorded version had other lines (such as comments) between its parts. Short lines like
406
+ # commands are judged by word pairs only, so a new command built from familiar words stays new.
407
+ known_pairs, known_words = set(), set()
408
+ for e in journal:
409
+ if e.get("action") == "DISCOVER":
410
+ lines = discovery_lines(e["extracted_data"])
411
+ known_pairs |= word_pairs(" ".join(lines)) # Pairs across line breaks, for text later rejoined
412
+ for line in lines:
413
+ known_pairs |= word_pairs(line)
414
+ known_words |= set(re.findall(r"\w+", line))
415
+
416
+ def line_is_known(line):
417
+ pairs, words = word_pairs(line), set(re.findall(r"\w+", line))
418
+ if len(re.findall(r"\w+", line)) >= 8 and words <= known_words:
419
+ return True
420
+ return len(pairs & known_pairs) >= KNOWN_LINE_THRESHOLD * len(pairs)
421
+
422
+ new_lines = [line for line in discovery_lines(parameter) if re.search(r"\w", line)]
423
+ return bool(new_lines) and all(line_is_known(line) for line in new_lines)
424
+
425
+ def read_more_target(parameter, page_state):
426
+ # The model sometimes writes "2 of 2" or "part 3"; the first number is the part it means
427
+ number = re.search(r'\d+', parameter)
428
+ return int(number.group()) - 1 if number else page_state["index"] + 1
429
+
430
+ def leave_check(action, page_state, current_url, journal=()):
431
+ # The model often left content pages without recording what it needed from them. The first attempt to
432
+ # leave a page it has recorded nothing from gets a cheap retry asking it to confirm; repeating the same
433
+ # action then goes through, so tasks where leaving without a discovery is normal lose only one retry.
434
+ # Search result pages are exempt (page_state only tracks content pages).
435
+ parts = page_state["parts"]
436
+ on_page = current_url and page_state["url"] == normalize_url(current_url) and parts
437
+ # Anything recorded from this page counts, on this visit or an earlier one
438
+ recorded_here = any(e.get("action") == "DISCOVER" and normalize_url(e.get("source") or "") == normalize_url(current_url)
439
+ for e in journal)
440
+ if not on_page or recorded_here or page_state.get("visit_discoveries") or page_state.get("leave_confirmed"):
441
+ return None
442
+ page_state["leave_confirmed"] = True # Asked once per visit
443
+ unread = len(parts) - len(page_state.get("seen_parts", {0}))
444
+ if unread > LONG_PAGE_UNREAD:
445
+ # Counting unread parts of a long page pushed the model to read all of them in order
446
+ unread_note = (f" It is a long page ({len(parts)} parts, {unread} unread): use its outline to judge "
447
+ "whether a part is worth reading.")
448
+ read_more = ", or READ_MORE with the number of a part from the outline that is likely to have it"
449
+ elif unread:
450
+ unread_note = f" {unread} of its {len(parts)} parts are still unread."
451
+ read_more = ", or READ_MORE if the unread parts may have it"
452
+ else:
453
+ unread_note = read_more = ""
454
+ return ("CONFIRM_LEAVE", f"You are about to leave {current_url} without recording anything from it.{unread_note} "
455
+ f"If it has information relevant to the objective, DISCOVER it now{read_more}. "
456
+ f"If it truly has nothing relevant, repeat your {action} to leave.")
457
+
458
+ def gap_check(thought, tried):
459
+ # The model is asked to write 'not covered' for missing parts; on a comparison it wrote "Available Libraries:
460
+ # Not covered" and finished anyway. A FINISH whose own reasoning says that is sent back to work on the gap,
461
+ # at most GAP_REFUSAL_LIMIT times per run so a part that cannot be found does not block finishing for good
462
+ if "not covered" not in str(thought).lower() or tried.get("gap_refusals", 0) >= GAP_REFUSAL_LIMIT:
463
+ return None
464
+ tried["gap_refusals"] = tried.get("gap_refusals", 0) + 1
465
+ return ("BLOCKED_FINISH", "Your own check marks a part of the objective as 'not covered'. Do not finish yet: work on "
466
+ "that part, for example with a SEARCH aimed at it. Only if you have already looked for it without success, "
467
+ "FINISH and say in the final result what could not be found.")
468
+
469
+ def check_decision(action, parameter, journal, tried, page_state, current_url, thought=""):
470
+ """Refusals that can be decided before acting. Returns (journal action, reason), or None if the decision may proceed."""
471
+ if action == "SEARCH":
472
+ query = search_query(parameter)
473
+ if not query.strip():
474
+ return "BLOCKED_SEARCH", "The SEARCH query was empty."
475
+ # Repeats are blocked outright; a warning alone does not stop small models from looping
476
+ if normalize_query(query) in [normalize_query(q) for q in tried["queries"]]:
477
+ # A model looking for something the web does not have kept resubmitting the same search (three times
478
+ # in one turn); from the second repeat it is told plainly that finishing with the gap is an option
479
+ repeats = tried.setdefault("repeated_searches", {})
480
+ repeats[normalize_query(query)] = repeats.get(normalize_query(query), 0) + 1
481
+ if repeats[normalize_query(query)] >= 2:
482
+ return "BLOCKED_SEARCH", (f"You have now tried to repeat the search '{query}' "
483
+ f"{repeats[normalize_query(query)]} times. It was already done and did not find "
484
+ "what you need. Either SEARCH with clearly different words, or accept that it "
485
+ "may not be available: FINISH and say in the final result that it could not be "
486
+ "found.")
487
+ return "BLOCKED_SEARCH", "This exact query was already searched. Use a new query, or DISCOVER from the current page."
488
+ elif action == "NAVIGATE":
489
+ if not parameter.strip():
490
+ return "BLOCKED_NAVIGATE", "NAVIGATE needs a full URL in the parameter."
491
+ target = normalize_url(determine_url(action, parameter))
492
+ if current_url and target == normalize_url(current_url):
493
+ return "BLOCKED_NAVIGATE", (f"{where_you_are(page_state, current_url)} You are already on this page. "
494
+ "Use READ_MORE to see its other parts, DISCOVER from it, or go somewhere else.")
495
+ # Going back to an earlier page is allowed; the cap stops back-and-forth loops between pages
496
+ if [normalize_url(u) for u in tried["urls"]].count(target) >= MAX_VISITS_PER_URL:
497
+ return "BLOCKED_NAVIGATE", f"This URL was already visited {MAX_VISITS_PER_URL} times. Pick a different page."
498
+ if action in ("SEARCH", "NAVIGATE"):
499
+ return leave_check(action, page_state, current_url, journal)
500
+ elif action == "READ_MORE":
501
+ total = len(page_state["parts"])
502
+ target = read_more_target(parameter, page_state)
503
+ if not (current_url and page_state["url"] == normalize_url(current_url)) or total == 0:
504
+ return "BLOCKED_READ_MORE", f"{where_you_are(page_state, current_url)} This page has no parts to read."
505
+ if not 0 <= target < total:
506
+ return "BLOCKED_READ_MORE", (f"There is no part {target + 1}. {where_you_are(page_state, current_url)} "
507
+ f"This page has {total} part(s).")
508
+ # Re-reading a part is allowed, but confirmed once per part (the same pattern as the leave check)
509
+ confirmed = page_state.setdefault("reread_confirmed", set())
510
+ if target in page_state.get("seen_parts", set()) and target != page_state["index"] and target not in confirmed:
511
+ confirmed.add(target)
512
+ return "CONFIRM_REREAD", (f"You already read part {target + 1} of this page; anything useful from it should "
513
+ "already be in DISCOVERIES SO FAR. Choose an unread part or another action, or "
514
+ "repeat this READ_MORE if you really need to read it again.")
515
+ elif action == "DISCOVER":
516
+ if not discovery_lines(parameter):
517
+ return "BLOCKED_DISCOVER", "The DISCOVER parameter was empty. Put the actual information in the parameter."
518
+ if is_known_discovery(parameter, journal):
519
+ return "DUPLICATE_DISCOVER", "Everything in that DISCOVER is already recorded in DISCOVERIES SO FAR."
520
+ elif action == "FINISH":
521
+ return finish_check(tried, journal, page_state, current_url) or gap_check(thought, tried)
522
+ else:
523
+ return f"UNKNOWN_{action}", f"'{action}' is not a valid action."
524
+ return None
525
+
526
+ def where_you_are(page_state, current_url):
527
+ # With several pages in one conversation the model lost track of which one was current (it paged through
528
+ # a 3-part article asking for "part 3 of 4" of a page it had left), so notes and refusals always say so
529
+ if not current_url:
530
+ return "No page is open yet."
531
+ if page_state["url"] == normalize_url(current_url) and page_state["parts"]:
532
+ return f"You are on {current_url} (part {page_state['index'] + 1} of {len(page_state['parts'])})."
533
+ return f"You are on {current_url}."
534
+
535
+ def finish_check(tried, journal, page_state=None, current_url=None):
536
+ # Asked to judge whether its discoveries "satisfy" the objective, a small model says yes too easily: on an
537
+ # opinion task it finished after one source and gave a summary instead of the opinion asked for. The first
538
+ # FINISH of each turn gets a cheap retry asking for a part-by-part check; repeating FINISH then goes through.
539
+ if tried.get("finish_confirmed"):
540
+ return None
541
+ tried["finish_confirmed"] = True # Reset at the start of every turn
542
+ return "CONFIRM_FINISH", f"{source_note(journal)}{unread_note(page_state or {}, current_url)} {FINISH_CHECK}"
543
+
544
+ def unread_note(page_state, current_url):
545
+ # On a how-to the agent finished after 3 of 6 parts of the recipe, missing the amounts on the unread parts
546
+ parts = page_state.get("parts") or []
547
+ if not (current_url and page_state.get("url") == normalize_url(current_url) and parts):
548
+ return ""
549
+ unread = len(parts) - len(page_state.get("seen_parts", {0}))
550
+ if unread > LONG_PAGE_UNREAD:
551
+ # A bare count pushed the model to read every remaining part in order, including ones about other topics
552
+ return (f" The current page still has {unread} unread parts: use its outline to judge whether any of them "
553
+ "is relevant before reading them.")
554
+ return f" The current page still has {unread} unread part(s) (READ_MORE)." if unread > 0 else ""
555
+
556
+ def source_note(journal):
557
+ # Facts, not a rule: some objectives need one source and some need many, so the model is only shown how
558
+ # many pages its discoveries came from (it judged one blog as covering "traditions around the world")
559
+ sources = []
560
+ for e in journal:
561
+ if e.get("action") == "DISCOVER":
562
+ source = normalize_url(e.get("source") or "") or "an unknown page"
563
+ if source not in sources:
564
+ sources.append(source)
565
+ found = sum(1 for e in journal if e.get("action") == "DISCOVER")
566
+ if not found:
567
+ return "You have no discoveries yet."
568
+ if found == 1:
569
+ return f"Your only discovery comes from {sources[0]}. Consider whether the objective needs more."
570
+ if len(sources) == 1:
571
+ return (f"All {found} of your discoveries come from a single page ({sources[0]}). "
572
+ "Consider whether the objective needs other sources.")
573
+ return f"Your {found} discoveries come from {len(sources)} different pages."
574
+
575
+ def action_options(page_state, current_url):
576
+ on_page = current_url and page_state["url"] == normalize_url(current_url) and page_state["parts"]
577
+ if on_page and page_state["index"] < len(page_state["parts"]) - 1:
578
+ read_more = f"READ_MORE to see part {page_state['index'] + 2} of {len(page_state['parts'])} of this page"
579
+ elif on_page:
580
+ read_more = "READ_MORE with a part number to revisit an earlier part (you are on the last part of this page)"
581
+ else:
582
+ read_more = "READ_MORE is not available here"
583
+ return (f"- {read_more}\n"
584
+ f"- NAVIGATE to another page: a new link, or an earlier page that still has information you need\n"
585
+ f"- SEARCH with a new query\n"
586
+ f"- FINISH if DISCOVERIES SO FAR already satisfies the objective")
587
+
588
+ def refusal_correction(action, reason, page_state, current_url):
589
+ # Listing the alternatives on the first refusal stops the model resubmitting the same action
590
+ return (f"REFUSED: {reason} Do NOT choose {action} with the same or similar parameter again. Choose a different action:\n"
591
+ f"{action_options(page_state, current_url)}\nReply with only the JSON object.")
592
+
593
+ def escalation_message(refusals, last_action, last_reason, page_state, current_url):
594
+ return (f"Your last {refusals} actions were all refused. Do NOT choose {last_action} again with the same or similar parameter. "
595
+ f"Last refusal reason: {last_reason} Choose a different action:\n"
596
+ f"{action_options(page_state, current_url)}")
597
+
598
+ def compact_journal(journal):
599
+ # Dropping old entries in batches (not one per turn) keeps the start of the journal unchanged
600
+ # between turns, so LM Studio can reuse its cached processing of it
601
+ # One line per entry instead of indented JSON: same information at about a third of the tokens
602
+ start = max(0, (len(journal) // JOURNAL_BATCH - 1) * JOURNAL_BATCH)
603
+ return "\n".join(journal_line(e) for e in journal[start:]) or "[None yet]"
604
+
605
+ def journal_line(e):
606
+ action = e.get("action", "")
607
+ if action == "DISCOVER":
608
+ detail = "(recorded in DISCOVERIES SO FAR)"
609
+ elif action == "FINISH":
610
+ detail = str(e.get("finding", ""))[:200]
611
+ else:
612
+ detail = str(e.get("requested") or e.get("part") or "")
613
+ detail = " ".join(detail.split()) # Refused discoveries carry multi-line text; keep each entry on one line
614
+ if len(detail) > MAX_JOURNAL_DETAIL_CHARS:
615
+ detail = detail[:MAX_JOURNAL_DETAIL_CHARS] + "..."
616
+ destination = e.get("destination", "")
617
+ if action == "NAVIGATE" and destination and normalize_url(destination) != normalize_url(detail):
618
+ detail += f" (landed on {destination})"
619
+ line = f"T{e.get('turn')} {action} {detail}".rstrip()
620
+ if e.get("reason") or e.get("error"):
621
+ line += f" -> {e.get('reason') or e.get('error')}"
622
+ thought = e.get("thought", "")
623
+ if thought:
624
+ line += f" | thought: {thought[:MAX_THOUGHT_CHARS] + '...' if len(thought) > MAX_THOUGHT_CHARS else thought}"
625
+ return line
626
+
627
+ def extract_message_content(response):
628
+ choices = getattr(response, "choices", None)
629
+ if choices is None and isinstance(response, dict):
630
+ choices = response.get("choices", [])
631
+
632
+ if isinstance(choices, list) and len(choices) > 0:
633
+ first_choice = choices[0]
634
+ else:
635
+ first_choice = choices
636
+
637
+ if hasattr(first_choice, "message"):
638
+ return (first_choice.message.content or "").strip()
639
+ elif isinstance(first_choice, dict) and "message" in first_choice:
640
+ msg = first_choice["message"]
641
+ return msg.get("content", "").strip() if isinstance(msg, dict) else msg.content.strip()
642
+ return first_choice.get("message", {}).get("content", "").strip()
643
+
644
+ def visited_summary(urls):
645
+ counts = {}
646
+ for u in urls:
647
+ counts[normalize_url(u)] = counts.get(normalize_url(u), 0) + 1
648
+ if not counts:
649
+ return "[None]"
650
+ return json.dumps([u if n == 1 else f"{u} (visited {n} times)" for u, n in counts.items()])
651
+
652
+ def shorten(text, limit):
653
+ text = " ".join(str(text).split())
654
+ return text if len(text) <= limit else text[:limit].rstrip() + "..."
655
+
656
+ def new_conversation():
657
+ return {"messages": None, "followups": 0, "tokens": 0, "note": None, "note_url": None, "shown_links": set()}
658
+
659
+ def set_note(conversation, note, current_url):
660
+ # A note is only used if the agent is still on the page it describes when the next turn starts
661
+ conversation.update(note=note, note_url=normalize_url(current_url) if current_url else None)
662
+
663
+ async def run_agent_turn(client, page, objective, journal, current_url, tried, page_state, conversation, loop_warning=None):
664
+ warning_prompt = f"\n\n⚠️ LOOP DETECTION WARNING: {loop_warning}" if loop_warning else ""
665
+
666
+ # Turns continue the previous conversation: LM Studio has already processed all of it, so only the
667
+ # note about what changed (a result, a new page part, or a whole new page) is new. Too many
668
+ # follow-ups, or a conversation nearing the context limit, starts over with a full prompt.
669
+ note = conversation["note"]
670
+ estimated_tokens = conversation["tokens"] + len(note or "") // 3
671
+ if (conversation["messages"] and note and current_url
672
+ and conversation["note_url"] == normalize_url(current_url)
673
+ and conversation["followups"] < MAX_FOLLOWUPS
674
+ and estimated_tokens < MAX_CONVERSATION_TOKENS):
675
+ conversation["followups"] += 1
676
+ print(f"⚡ Continuing the conversation (follow-up {conversation['followups']}/{MAX_FOLLOWUPS}, ~{estimated_tokens} tokens)")
677
+ messages = conversation["messages"] + [{"role": "user", "content": f"{note}{warning_prompt}\n\n{TURN_INSTRUCTIONS}"}]
678
+ else:
679
+ conversation.update(followups=0, shown_links=set())
680
+ messages = await build_full_prompt(page, objective, journal, current_url, tried, page_state, warning_prompt, conversation["shown_links"])
681
+ conversation["note"] = None
682
+
683
+ decision, refused, messages, tokens = await request_decision(client, messages, journal, tried, page_state, current_url)
684
+ conversation.update(messages=messages, tokens=tokens)
685
+ return decision, refused
686
+
687
+ TURN_INSTRUCTIONS = """If the CURRENT ENVIRONMENT contains information relevant to the objective that is not in DISCOVERIES SO FAR, choose DISCOVER now.
688
+ Determine your next tactical move. Output your decision in the mandatory JSON format:"""
689
+
690
+ async def build_full_prompt(page, objective, journal, current_url, tried, page_state, warning_prompt, shown_links=None):
691
+ print("🔍 Scraping and preparing current environment text layout...")
692
+ environment = await build_environment(page, current_url, page_state, {normalize_url(u) for u in tried["urls"]}, shown_links)
693
+
694
+ # Shortened in the prompt (the full text is in the log and the report, and duplicates are checked against
695
+ # it in code): the growing discoveries list had become a quarter of every fresh prompt
696
+ discoveries = [shorten(e["extracted_data"], MAX_DISCOVERY_PROMPT_CHARS) for e in journal if e.get("action") == "DISCOVER"]
697
+ recent_journal = compact_journal(journal)
698
+
699
+ # LM Studio only reuses the part of the prompt that is identical to the previous turn's, up to the
700
+ # first change. Sections are ordered from least to most often changed, carry no counters, and the
701
+ # journal only grows at its end, so each turn mostly adds text instead of changing earlier text.
702
+ # The page content goes last, where small models pay the most attention.
703
+ user_payload = f"""### HIGH-LEVEL OBJECTIVE
704
+ {objective}
705
+
706
+ ### DISCOVERIES SO FAR
707
+ {json.dumps(discoveries, indent=2) if discoveries else "[None yet]"}
708
+
709
+ ### RECENT RESEARCH JOURNAL
710
+ {recent_journal}
711
+
712
+ ### ALREADY TRIED
713
+ Queries (do not repeat): {json.dumps(tried["queries"]) if tried["queries"] else "[None]"}
714
+ URLs visited (you may return to one that still has information you need, at most {MAX_VISITS_PER_URL} visits each): {visited_summary(tried["urls"])}
715
+
716
+ ### CURRENT ENVIRONMENT
717
+ Active URL: {current_url if current_url else "None (Loop Initializing)"}
718
+ {environment}{warning_prompt}
719
+
720
+ {TURN_INSTRUCTIONS}"""
721
+ return [
722
+ {"role": "system", "content": SYSTEM_PROMPT},
723
+ {"role": "user", "content": user_payload}
724
+ ]
725
+
726
+ async def request_decision(client, messages, journal, tried, page_state, current_url):
727
+ # Cheap retries: a reply that is invalid or would be refused is answered with a short correction
728
+ # appended to the same conversation. Everything before the correction is identical to what LM Studio
729
+ # just processed, so it only reads the correction instead of the whole prompt again. Resending the
730
+ # prompt unchanged would not help: the same prompt returns the same answer.
731
+ messages = list(messages)
732
+ refused = []
733
+ json_failures = 0
734
+ while True:
735
+ response = await client.chat.completions.create(
736
+ model=MODEL,
737
+ messages=messages,
738
+ temperature=0.2,
739
+ response_format=RESPONSE_FORMAT
740
+ )
741
+ original_content = extract_message_content(response)
742
+ raw_content = original_content
743
+
744
+ if raw_content.startswith("```"):
745
+ raw_content = re.sub(r'^```json\s*|^```\s*|```$', '', raw_content, flags=re.MULTILINE).strip()
746
+
747
+ try:
748
+ action_decision = json.loads(raw_content)
749
+ if not isinstance(action_decision, dict):
750
+ raise ValueError("the reply was JSON but not an object")
751
+ except (json.JSONDecodeError, ValueError) as parse_err:
752
+ json_failures += 1
753
+ print(f"⚠️ Model returned invalid JSON (attempt {json_failures}/{MAX_JSON_RETRIES}): {raw_content[:200]}")
754
+ if json_failures >= MAX_JSON_RETRIES:
755
+ raise ValueError(f"No valid JSON decision after {MAX_JSON_RETRIES} attempts.") from parse_err
756
+ messages += [{"role": "assistant", "content": original_content},
757
+ {"role": "user", "content": f"That reply was not a valid JSON object ({parse_err}). Reply with only the JSON object in the mandatory format."}]
758
+ continue
759
+
760
+ action = str(action_decision.get("action", "FINISH")).upper()
761
+ parameter = str(action_decision.get("parameter", "")).strip()
762
+ refusal = check_decision(action, parameter, journal, tried, page_state, current_url,
763
+ action_decision.get("thought_process", ""))
764
+ if not refusal or len(refused) >= MAX_CHEAP_RETRIES:
765
+ usage = getattr(response, "usage", None)
766
+ tokens = (getattr(usage, "prompt_tokens", 0) or 0) + (getattr(usage, "completion_tokens", 0) or 0)
767
+ return action_decision, refused, messages + [{"role": "assistant", "content": original_content}], tokens
768
+
769
+ refused.append({"action": action, "parameter": parameter, "journal_action": refusal[0], "reason": refusal[1]})
770
+ print(f"♻️ Cheap retry {len(refused)}/{MAX_CHEAP_RETRIES}: [{action}] refused. {refusal[1]}")
771
+ correction = refusal_correction(action, refusal[1], page_state, current_url)
772
+ if refusal[0] in ("CONFIRM_LEAVE", "CONFIRM_FINISH", "CONFIRM_REREAD"):
773
+ # Not a true refusal: repeating the action is an allowed answer, so no "do not repeat" wording
774
+ correction = f"CHECK: {refusal[1]} Reply with only the JSON object."
775
+ elif len(refused) >= 2:
776
+ correction = escalation_message(len(refused), action, refusal[1], page_state, current_url) + "\nReply with only the JSON object."
777
+ messages += [{"role": "assistant", "content": original_content}, {"role": "user", "content": correction}]
778
+
779
+ def write_research_log(objective, journal):
780
+ with open("research_log.txt", "w", encoding="utf-8") as log:
781
+ log.write(f"OBJECTIVE: {objective}\n")
782
+ log.write(json.dumps(journal, indent=2))
783
+
784
+ NUMBER_PATTERN = re.compile(r"\d+(?:[.,/]\d+)*")
785
+
786
+ def unverified_numbers(text, discoveries):
787
+ """Numbers in the final result that appear in none of the discoveries.
788
+
789
+ A small model writing a how-to filled a gap with an invented quantity ("45 grams of lukewarm water (about
790
+ 1/4 cup)"); numbers are where such errors do the most harm and the easiest place to check them.
791
+ """
792
+ text = re.sub(r"\[\d+\]", " ", str(text)) # Citation markers like [2]
793
+ text = re.sub(r"(?m)^\s*\d+[.)]\s", " ", text) # Numbered list items
794
+ known = {n.replace(",", "") for e in discoveries for n in NUMBER_PATTERN.findall(e["extracted_data"])}
795
+ unverified = []
796
+ for number in NUMBER_PATTERN.findall(text):
797
+ if number.replace(",", "") not in known and number not in unverified:
798
+ unverified.append(number)
799
+ return unverified
800
+
801
+ def write_report(objective, journal):
802
+ """A readable report with the final result and every discovery, so nothing depends on the model's
803
+ summary being complete. Written however the run ends."""
804
+ finish = next((e for e in reversed(journal) if e.get("action") == "FINISH"), None)
805
+ found = [e for e in journal if e.get("action") == "DISCOVER"]
806
+ last_turn = journal[-1].get("turn", 0) if journal else 0
807
+ lines = ["# Research report", "", f"**Objective:** {objective}", ""]
808
+ if finish:
809
+ final = finish.get("finding") or "_(The agent finished without writing a final result.)_"
810
+ lines += [f"**Status:** finished by the agent on turn {finish.get('turn')}", "", "## Final result", "", final]
811
+ unchecked = unverified_numbers(final, found)
812
+ if unchecked:
813
+ lines += ["", f"> ⚠️ Numbers not found in the recorded discoveries: {', '.join(unchecked)}. "
814
+ "Check them before relying on this result."]
815
+ else:
816
+ lines += [f"**Status:** stopped before the agent finished (after turn {last_turn})", "", "## Final result", "",
817
+ "_(The agent did not finish; see the discoveries below.)_"]
818
+ lines += ["", f"## Discoveries ({len(found)})"]
819
+ for i, e in enumerate(found, start=1):
820
+ lines += ["", f"### {i}. Turn {e.get('turn')}, from {e.get('source') or 'unknown page'}", "", e["extracted_data"]]
821
+ with open(REPORT_FILE, "w", encoding="utf-8") as report:
822
+ report.write("\n".join(lines) + "\n")
823
+
824
+ COMPOSE_SYSTEM = ("You write the final result of a web research task, using only the findings that were recorded "
825
+ "during it.")
826
+
827
+ async def compose_final_result(client, objective, journal):
828
+ """Writes the final result in a model call whose only job is writing.
829
+
830
+ Deciding what to browse and writing the deliverable compete for a small model's attention: in a live run
831
+ its reasoning noted the objective's request for an opinion was not covered, then it finished with a list
832
+ of findings anyway. Given only the objective and every discovery, it can focus on what was asked for.
833
+ """
834
+ found = [e for e in journal if e.get("action") == "DISCOVER"]
835
+ listing = "\n\n".join(f"[{i}] (from {e.get('source') or 'an unknown page'})\n{e['extracted_data']}"
836
+ for i, e in enumerate(found, start=1)) or "[None]"
837
+ # The agent's own FINISH draft is not shown: on a how-to it already contained an invented quantity, which
838
+ # this step then copied; everything here must come from the recorded discoveries
839
+ # Rules a small model skipped when they came late in one long instruction (it listed four items when asked
840
+ # for three, and added "My view" to a request for strengths) are given as a short numbered list
841
+ request = (f"### OBJECTIVE\n{objective}\n\n### DISCOVERIES\n{listing}\n\n"
842
+ "Write the complete final result the OBJECTIVE asks for: do exactly what it asks (for example answer "
843
+ "the question, write the summary or the plan, or give the opinion). Rules:\n"
844
+ "1. Use only the information in the DISCOVERIES. Do not fill gaps from your own knowledge or with "
845
+ "guesses: no amounts, numbers, steps or advice that the discoveries do not contain. Where a detail the "
846
+ "objective asks for is missing, say that it was not found.\n"
847
+ "2. If the objective asks for a specific number of items, give exactly that many (the best supported).\n"
848
+ "3. Refer to the discoveries that support each point by their numbers, like [2], and only cite a number "
849
+ "for something that discovery actually says.\n"
850
+ "4. Do not add a 'My view:' paragraph unless the objective explicitly asks for your view, opinion or "
851
+ "recommendation; if it does, give it in a paragraph that starts with 'My view:'.\n"
852
+ "5. Write plain text, not JSON.")
853
+ # A fresh prompt with only the discoveries, not the browsing conversation: continuing that conversation let
854
+ # the model use page text it had read but not recorded, and cite discovery numbers that did not say it
855
+ messages = [{"role": "system", "content": COMPOSE_SYSTEM}, {"role": "user", "content": request}]
856
+ response = await client.chat.completions.create(model=MODEL, messages=messages, temperature=0.3,
857
+ max_tokens=COMPOSE_MAX_TOKENS)
858
+ text = extract_message_content(response)
859
+ if text.startswith("{"): # Habit from every earlier turn: unwrap a JSON reply instead of reporting it raw
860
+ try:
861
+ reply = json.loads(text)
862
+ text = str(reply.get("parameter") or reply.get("thought_process") or text)
863
+ except (json.JSONDecodeError, AttributeError):
864
+ pass
865
+ return text.strip()
866
+
867
+ async def load_page(page, url=None):
868
+ """Loads a URL (or reloads the page) and returns Playwright's response, which may be None."""
869
+ if url:
870
+ response = await page.goto(url, wait_until="domcontentloaded", timeout=30000)
871
+ else:
872
+ response = await page.reload(wait_until="domcontentloaded", timeout=30000)
873
+ try:
874
+ await page.wait_for_load_state("networkidle", timeout=5000)
875
+ except Exception:
876
+ pass # Busy pages rarely go fully idle; the DOM is already usable
877
+ await asyncio.sleep(1)
878
+ return response
879
+
880
+ async def main():
881
+ client = AsyncOpenAI(
882
+ base_url=BASE_URL,
883
+ api_key=API_KEY
884
+ )
885
+
886
+ print("🤖 ==================================================== 🤖")
887
+ print("🚀 Initializing Autonomous Discovery Agent Setup...")
888
+ objective = input("🎯 Define the Agent's High-Level Objective: ").strip()
889
+ if not objective:
890
+ print("⚠️ Objective cannot be empty.")
891
+ return
892
+
893
+ journal = []
894
+ current_url = None
895
+ turn_counter = 1
896
+ max_turns = 100
897
+ tried = {"queries": [], "urls": []}
898
+ page_state = {"url": None, "parts": [], "index": 0}
899
+ conversation = new_conversation()
900
+ loop_msg = None
901
+ consecutive_refusals = 0
902
+ turns_without_discovery = 0
903
+ turns_since_objective_check = 0
904
+
905
+ async with async_playwright() as p:
906
+ browser = await p.chromium.launch(headless=False)
907
+ # No user_agent override: headed Chromium already reports a real, version-matched Chrome UA
908
+ context = await browser.new_context(
909
+ java_script_enabled=True,
910
+ viewport={"width": 1280, "height": 720}
911
+ )
912
+ page = await context.new_page()
913
+
914
+ try:
915
+ while turn_counter <= max_turns:
916
+ print(f"\n🧠 [TURN {turn_counter}/{max_turns}] Active reasoning cycle executing...")
917
+ tried["finish_confirmed"] = False # The finish check runs once per turn
918
+
919
+ try:
920
+ decision, refused = await run_agent_turn(client, page, objective, journal, current_url, tried, page_state, conversation, loop_warning=loop_msg)
921
+ thought = decision.get("thought_process", "No reasoning recorded.")
922
+ action = str(decision.get("action", "FINISH")).upper()
923
+ parameter = str(decision.get("parameter", "")).strip()
924
+ loop_msg = None
925
+ except ValueError as parse_err:
926
+ print(f"⚠️ {parse_err} Skipping turn.")
927
+ journal.append({"turn": turn_counter, "action": "INVALID_OUTPUT", "error": str(parse_err)})
928
+ turn_counter += 1
929
+ continue
930
+ except Exception as err:
931
+ print(f"❌ Local AI API transaction failed: {err}")
932
+ print(f"💡 Check that LM Studio is running at {BASE_URL} and model '{MODEL}' is loaded.")
933
+ break
934
+
935
+ # Replies refused and retried within this turn stay in the journal so the model sees what failed
936
+ for r in refused:
937
+ journal.append({"turn": turn_counter, "action": "REFUSED_IN_TURN", "requested": f"{r['action']} {r['parameter'][:120]}", "reason": r["reason"]})
938
+
939
+ print(f"💡 Agent Thoughts: {thought}")
940
+ print(f"🎬 Chosen Action: [{action}] -> Parameter: {parameter}")
941
+
942
+ refusal = check_decision(action, parameter, journal, tried, page_state, current_url, thought)
943
+ if refusal:
944
+ # Only reached once this turn's cheap retries are used up
945
+ print(f"⚠️ Action refused: {refusal[1]}")
946
+ journal.append({"turn": turn_counter, "thought": thought, "action": refusal[0], "requested": parameter, "reason": refusal[1]})
947
+ loop_msg = f"Your {action} was refused. {refusal[1]}"
948
+ set_note(conversation, f"RESULT: Nothing changed. {where_you_are(page_state, current_url)}", current_url)
949
+
950
+ elif action == "FINISH":
951
+ print("\n🎯 Agent has signaled mission complete. Writing the final result...")
952
+ finish_entry = {"turn": turn_counter, "thought": thought, "action": "FINISH", "finding": parameter}
953
+ journal.append(finish_entry)
954
+ try:
955
+ final = await compose_final_result(client, objective, journal)
956
+ if final:
957
+ finish_entry.update(draft=parameter, finding=final)
958
+ except Exception as err:
959
+ print(f"⚠️ Could not write the final result separately ({err}); keeping the agent's FINISH text.")
960
+ break
961
+
962
+ elif action in ["SEARCH", "NAVIGATE"]:
963
+ target_url = determine_url(action, parameter)
964
+ print(f"🌐 Driving Playwright to cross-domain target: {target_url}")
965
+ try:
966
+ response = await load_page(page, target_url)
967
+ current_url = page.url
968
+ status = getattr(response, "status", None) or 0
969
+
970
+ if action == "SEARCH":
971
+ # The search engine sometimes returns an error page; reload once before giving up
972
+ if await count_search_results(page) == 0:
973
+ print("⚠️ Search returned no results. Retrying once...")
974
+ await asyncio.sleep(3)
975
+ await load_page(page)
976
+ current_url = page.url
977
+ if await count_search_results(page) == 0:
978
+ print("❌ Search still returned no results.")
979
+ journal.append({"turn": turn_counter, "thought": thought, "action": "FAILED_SEARCH", "requested": parameter,
980
+ "error": "No results (search engine error or no matches). This query may be tried again."})
981
+ else:
982
+ tried["queries"].append(search_query(parameter))
983
+ journal.append({"turn": turn_counter, "thought": thought, "action": action, "requested": parameter, "destination": current_url})
984
+ else:
985
+ tried["urls"].append(target_url)
986
+ if normalize_url(current_url) != normalize_url(target_url):
987
+ tried["urls"].append(current_url) # Also remember where redirects landed
988
+ if status >= 400:
989
+ # Often a guessed URL; reported as a failure so the model does not treat the
990
+ # error page as content (and is not asked to record something from it)
991
+ print(f"❌ The page returned HTTP {status}.")
992
+ journal.append({"turn": turn_counter, "thought": thought, "action": "FAILED_NAVIGATE", "requested": parameter,
993
+ "error": f"The page returned HTTP {status} (for example 404: not found). "
994
+ "Use a link shown on a page or in search results instead of guessing URLs."})
995
+ else:
996
+ journal.append({"turn": turn_counter, "thought": thought, "action": action, "requested": parameter, "destination": current_url})
997
+ except Exception as b_err:
998
+ print(f"❌ Failed to reach target destination: {b_err}")
999
+ if action == "NAVIGATE":
1000
+ tried["urls"].append(target_url)
1001
+ journal.append({"turn": turn_counter, "thought": thought, "action": f"FAILED_{action}", "requested": parameter, "error": str(b_err)})
1002
+
1003
+ # The new page joins the running conversation instead of forcing a full prompt rebuild
1004
+ outcome = journal[-1]
1005
+ if "error" in outcome:
1006
+ result = f"RESULT: {outcome['action']} '{parameter}' failed: {outcome['error'][:200]}"
1007
+ else:
1008
+ added = search_query(parameter) if action == "SEARCH" else target_url
1009
+ result = f"RESULT: {action} '{parameter}' done. ALREADY TRIED now also includes: {added}"
1010
+ if action == "NAVIGATE":
1011
+ visits = [normalize_url(u) for u in tried["urls"]].count(normalize_url(target_url))
1012
+ result = f"RESULT: NAVIGATE '{parameter}' done (visit {visits} of {MAX_VISITS_PER_URL} allowed for this URL)."
1013
+ environment = await build_environment(page, current_url, page_state, {normalize_url(u) for u in tried["urls"]}, conversation["shown_links"])
1014
+ if "error" in outcome and page_state["url"] == normalize_url(current_url):
1015
+ page_state["leave_confirmed"] = True # Nothing to record on an error page
1016
+ set_note(conversation, f"{result}\n\n### CURRENT ENVIRONMENT (replaces the earlier one)\n"
1017
+ f"Active URL: {current_url}\n{environment}", current_url)
1018
+
1019
+ elif action == "READ_MORE":
1020
+ target = read_more_target(parameter, page_state)
1021
+ page_state["index"] = target
1022
+ page_state.setdefault("seen_parts", {0}).add(target)
1023
+ print(f"📖 Showing part {target + 1} of {len(page_state['parts'])} of the current page.")
1024
+ journal.append({"turn": turn_counter, "thought": thought, "action": "READ_MORE", "part": f"{target + 1} of {len(page_state['parts'])}"})
1025
+ set_note(conversation, f"RESULT: READ_MORE done. {where_you_are(page_state, current_url)} "
1026
+ f"The CURRENT ENVIRONMENT now shows:\n{render_part(page_state)}\n"
1027
+ f"The links listed earlier for this page still apply.", current_url)
1028
+
1029
+ elif action == "DISCOVER":
1030
+ print(f"📝 Logging critical discovery element: {parameter}")
1031
+ journal.append({"turn": turn_counter, "thought": thought, "action": "DISCOVER", "source": current_url, "extracted_data": parameter})
1032
+ page_state["visit_discoveries"] = page_state.get("visit_discoveries", 0) + 1
1033
+ # A discovery is when the objective can become complete; on a one-answer question the model
1034
+ # recorded the full answer, said "the objective has been met", then kept reading for
1035
+ # "interesting" details, so completion is checked right here
1036
+ set_note(conversation, "RESULT: Your DISCOVER was recorded and is now part of DISCOVERIES SO FAR. "
1037
+ f"{where_you_are(page_state, current_url)} The page is unchanged. "
1038
+ "Do not record the same information again. If DISCOVERIES SO FAR now cover "
1039
+ "every part of the OBJECTIVE, choose FINISH now; do not read further for extra "
1040
+ "or merely interesting details.", current_url)
1041
+
1042
+ # A single refusal gets a short warning; repeated refusals get an explicit list of other options
1043
+ last_entry = journal[-1]["action"] if journal else ""
1044
+ if last_entry.startswith(("BLOCKED_", "UNKNOWN_")) or last_entry == "DUPLICATE_DISCOVER":
1045
+ consecutive_refusals += 1
1046
+ if consecutive_refusals >= 2:
1047
+ print(f"⚠️ {consecutive_refusals} refused actions in a row. Escalating loop warning.")
1048
+ loop_msg = escalation_message(consecutive_refusals, action, journal[-1].get("reason", ""), page_state, current_url)
1049
+ else:
1050
+ consecutive_refusals = 0
1051
+
1052
+ # Without a nudge the model keeps browsing after it already has enough; after several turns
1053
+ # with no new discovery it is asked to weigh its discoveries against the objective
1054
+ if action == "DISCOVER" and last_entry == "DISCOVER":
1055
+ turns_without_discovery = 0
1056
+ else:
1057
+ turns_without_discovery += 1
1058
+ # Fires every STALL_TURNS stalled turns rather than every turn: repeated each turn it crowded out
1059
+ # the current page and pushed the model to navigate away without reading what was in front of it
1060
+ if turns_without_discovery and turns_without_discovery % STALL_TURNS == 0:
1061
+ print(f"⚠️ No new discoveries in {turns_without_discovery} turns. Adding progress check.")
1062
+ progress = (f"PROGRESS CHECK: no new discoveries in the last {turns_without_discovery} turns. {READ_FIRST} "
1063
+ f"Then {CHECK_PARTS} If every part is covered, choose FINISH with {FINAL_RESULT} in the "
1064
+ "parameter. If not, state in thought_process exactly what is still missing, then go and find "
1065
+ f"that specific thing. {GIVE_UP_RULE}")
1066
+ loop_msg = f"{loop_msg}\n{progress}" if loop_msg else progress
1067
+ turns_since_objective_check = 0
1068
+ else:
1069
+ # Discoveries on loosely related topics keep resetting the stall count, so the objective is
1070
+ # also re-checked on a fixed schedule to stop the model drifting away from it
1071
+ turns_since_objective_check += 1
1072
+ if turns_since_objective_check >= OBJECTIVE_CHECK_EVERY:
1073
+ turns_since_objective_check = 0
1074
+ count = sum(1 for e in journal if e.get("action") == "DISCOVER")
1075
+ print(f"🧭 Objective check after {turn_counter} turns ({count} discoveries).")
1076
+ check = (f"OBJECTIVE CHECK: you have recorded {count} discoveries in {turn_counter} turns. "
1077
+ f"{source_note(journal)} {READ_FIRST} "
1078
+ f"Then {CHECK_PARTS} If every part is covered, choose FINISH with {FINAL_RESULT} in the "
1079
+ "parameter. If not, state in thought_process exactly what is still missing and go directly for it; "
1080
+ f"do not spend turns on topics outside the objective. {GIVE_UP_RULE}")
1081
+ loop_msg = f"{loop_msg}\n{check}" if loop_msg else check
1082
+
1083
+ # Saved every turn as well, so a crash or a killed process still leaves a current report
1084
+ write_research_log(objective, journal)
1085
+ write_report(objective, journal)
1086
+
1087
+ turn_counter += 1
1088
+ print("\n⏱️ Cycle pacing check complete. Refreshing navigation loop pipeline...")
1089
+ await asyncio.sleep(TURN_PAUSE_SECONDS)
1090
+ finally:
1091
+ # Always saved, however the run ends: FINISH, the turn limit, Ctrl+C or an outside time limit
1092
+ write_research_log(objective, journal)
1093
+ write_report(objective, journal)
1094
+
1095
+ print(f"\n🔒 Shutting down browser instance. Report: {REPORT_FILE}; full journal: research_log.txt.")
1096
+ await browser.close()
1097
+
1098
+ def run():
1099
+ """Command-line entry point (installed as `localagentbrowser`)."""
1100
+ try:
1101
+ asyncio.run(main())
1102
+ except KeyboardInterrupt:
1103
+ print("\n⏹️ Stopped. Findings so far are in research_log.txt.")
1104
+
1105
+
1106
+ if __name__ == "__main__":
1107
+ run()