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 +1107 -0
- localagentbrowser-1.41.dist-info/METADATA +358 -0
- localagentbrowser-1.41.dist-info/RECORD +7 -0
- localagentbrowser-1.41.dist-info/WHEEL +5 -0
- localagentbrowser-1.41.dist-info/entry_points.txt +2 -0
- localagentbrowser-1.41.dist-info/licenses/LICENSE +21 -0
- localagentbrowser-1.41.dist-info/top_level.txt +1 -0
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()
|