Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ Long-running endpoints stream **Server-Sent Events (SSE)**. Protected endpoints
| `POST` | `/jobs/cover-letter` | SSE: tailored cover letter |
| `POST` | `/jobs/tailored-cv` | SSE: tailored, structured CV |
| `POST` | `/apply` | SSE: browser application (fill → review → submit) |
| `POST` | `/browser-input/{session_id}` | Click/type/key/scroll/`submit`/`done`/`skip` into the live session |
| `POST` | `/browser-input/{session_id}` | Click/type/key/scroll/`submit`/`done`/`skip` into the live session, or `{type: answers}` to put answers you gave in the review on the form |
| `GET` | `/browser-stream/{session_id}` | SSE: live JPEG frames (CDP screencast) |
| `GET` / `POST` | `/applications` | List / create tracked applications |
| `PATCH` | `/applications/{id}/status` | Update one of *your* applications' status + notes |
Expand Down Expand Up @@ -200,7 +200,7 @@ Labels: **High ≥ 65 %**, **Medium 35–65 %**, **Low < 35 %**. **Job fit** (`s
- **No telemetry** — browser-use's product telemetry (PostHog) and cloud sync default to on and would send each run's task prompt, actions and visited URLs to browser-use; `browser_agent.py` defaults `ANONYMIZED_TELEMETRY` / `BROWSER_USE_CLOUD_SYNC` to `false` and `docker-compose.yml` sets both.
- **Network limits** — the agent's browser can't open raw-IP URLs, `localhost` or the internal service hostnames.
- **CV upload** — the tailored CV PDF if you generated one, otherwise your uploaded original.
- **Questions** — the agent isn't given facts to answer from. It sends each page's questions to an `answer_questions` action (`answers.py`), which answers from your saved answers, then your persona, and drafts written ones, and says where each answer came from. A model maps each question to the fact that answers it, but the value comes from the fact, and code checks the reading (a cited fact, one of the form's options, a yes/no fact's direction, a UK fact not used for another country). Contact details, salary and equality answers reach that model only as "this fact exists". Questions nothing answers are left blank and listed first in the review, which shows every answer and its source.
- **Questions** — the agent isn't given facts to answer from. It sends each page's questions to an `answer_questions` action (`answers.py`), which answers from your saved answers, then your persona, and drafts written ones, and says where each answer came from. A model maps each question to the fact that answers it, but the value comes from the fact, and code checks the reading (a cited fact, one of the form's options, a yes/no fact's direction, a UK fact not used for another country). Contact details, salary and equality answers reach that model only as "this fact exists". Questions nothing answers are left blank and listed first in the review, which shows every answer and its source. What you answer there is saved, and while the browser is paused a short follow-up agent puts it on the form (like the one that clicks submit, it never sends the form). A salary question with bands gets the one band that holds your figure; on a boundary it's left for you.

---

Expand Down
40 changes: 37 additions & 3 deletions api/app/answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,35 @@ def pick_option(value: str, options: list[str]) -> str | None:
return None


_AMOUNT = re.compile(r"(\d+(?:,\d{3})*(?:\.\d+)?)\s*(k\b|thousand\b)?", re.I)
_OPEN_TOP = re.compile(r"\+|\b(?:above|over|more than|or more|plus|at least|minimum)\b", re.I)
_OPEN_BOTTOM = re.compile(r"\b(?:up to|under|below|less than|or less|max(?:imum)?)\b", re.I)


def _money_range(text: str) -> tuple[float, float] | None:
"""The amounts a salary answer or band states, as (low, high): "£30k-£35k", "£35,000+", "Up to £25,000"."""
amounts = [float(n.replace(",", "")) * (1000 if unit else 1) for n, unit in _AMOUNT.findall(text)]
if not amounts:
return None
if len(amounts) >= 2:
return min(amounts[:2]), max(amounts[:2])
if _OPEN_TOP.search(text):
return amounts[0], float("inf")
if _OPEN_BOTTOM.search(text):
return 0.0, amounts[0]
return amounts[0], amounts[0]


def pick_band(value: str, options: list[str]) -> str | None:
"""The one salary band that holds the whole of `value` ("£31,500" is in "£30,000-£35,000"); None when no
band or more than one does (31,500 on a band boundary, a range that straddles two, "Negotiable")."""
wanted = _money_range(value)
if not wanted:
return None
holding = [o for o in options if (band := _money_range(o)) and band[0] <= wanted[0] and wanted[1] <= band[1]]
return holding[0] if len(holding) == 1 else None


_UK_RE = re.compile(r"\b(?:uk|u\.k\.|united kingdom|great britain|britain|british|england|english|scotland|"
r"scottish|wales|welsh|northern ireland|gb)\b", re.I)
# Bare "us" counts only in capitals ("the US"): "tell us", "join us".
Expand Down Expand Up @@ -370,8 +399,12 @@ def resolve_fact(question: dict, reading: dict, facts: dict[str, Fact], job_loca
first = cited[0]

if len(cited) == 1 and first.private:
# The model never saw the value, so it can only have said which fact applies.
answer = pick_option(first.value, options)
# The model never saw the value, so it can only have said which fact applies. A salary goes in the band
# that holds it.
if first.id.startswith("salary_") and any(_money_range(o) for o in options):
answer = pick_band(first.value, options)
else:
answer = pick_option(first.value, options)
return (answer, cited) if answer else None

if options:
Expand Down Expand Up @@ -419,7 +452,8 @@ def resolve_fact(question: dict, reading: dict, facts: dict[str, Fact], job_loca
"- A fact marked (private) has its value hidden: cite its id when it is the fact asked for, and leave "
"answer \"\"; Windrush fills in the value.\n"
"- Don't compute or estimate: years of experience, counts, grades or dates are answered only by a fact "
"that states them.\n"
"that states them. The one exception: a salary question whose options are bands or ranges is answered by "
"the salary expectations fact; cite it and Windrush picks the band that holds the figure.\n"
"- Read the question's direction: 'Will you require sponsorship?' and 'Can you work without "
"sponsorship?' take opposite answers from the same fact.\n"
"- A declaration or consent (a privacy notice, that the information is accurate, background or reference "
Expand Down
139 changes: 115 additions & 24 deletions api/app/browser_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,16 @@ async def _interactive_session(
on_submit: Callable[[], Awaitable[tuple[bool, str]]],
notes: str = "",
questions: list[dict] | None = None,
on_answers: Callable[[list[dict]], Awaitable[dict]] | None = None,
) -> AsyncGenerator[dict, None]:
"""
Hand control to the user until they finish. Commands (from BrowserView.tsx):
JSON {type: click|type|key|scroll} → replayed onto the page via CDP
'submit' → a short agent clicks the final submit button (submitted if it confirms)
'done' → the user submitted it themselves
'skip' / 'cancel' / 'abort' → abandon (not submitted)
JSON {type: answers, answers: [{question, answer, kind, options}]} → `on_answers` puts the answers the
user gave in the review on the form; its result ({action, notes, questions}) is the next event
any other text → typed into the focused field
`notes` (the agent's own summary) and `questions` (what answer_questions answered) ride on the first
event only; the UI keeps them for the review.
Expand Down Expand Up @@ -399,6 +402,21 @@ def interactive(action: str, reason: str, screenshot: str) -> dict:
pass
word = (cmd.get("type", "") if cmd else str(raw)).strip().lower()

if word == "answers" and cmd is not None and on_answers is not None:
answers = _review_answers(cmd.get("answers"))
if not answers:
yield interactive("There were no answers to fill in", again, await _screenshot_b64(browser_session))
continue
count = f"{len(answers)} answer{'' if len(answers) == 1 else 's'}"
yield {"action": f"Filling in your {count}…", "screenshot": None, "blocked": False, "reason": None,
"done": False}
update = await on_answers(answers)
event = interactive(update.get("action", f"Filled in your {count}"), again,
await _screenshot_b64(browser_session))
event.update({k: update[k] for k in ("notes", "questions") if k in update})
yield event
continue

if word == "submit":
yield {"action": "Submitting the application…", "screenshot": None, "blocked": False,
"reason": None, "done": False}
Expand Down Expand Up @@ -690,6 +708,53 @@ async def guarded_act(action, browser_session, sensitive_data=None, **kwargs):
return tools


# Answers the candidate gave in the review, put on the paused form by a short follow-up agent.
_MAX_REVIEW_ANSWERS = 20
_FILL_TASK = (
"The job application form on the current page has been filled in, and the candidate has now answered some "
"questions that were left blank (or rewritten an answer). Put each answer below on the form exactly as given: "
"choose the given option for a dropdown, radio or checkbox question, type the given text into a text box, and "
"use fill_answer for a written answer. Change nothing else, don't navigate away, and do NOT submit the form "
"or click any button that sends it. Then call done with success=true, or with success=false naming each "
"question you couldn't find on the page.\n\n"
)


def _review_answers(raw) -> list[dict]:
"""The answers a review 'answers' command carries, checked: [{question, answer, kind, options}]."""
out = []
for item in raw if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
question = " ".join(str(item.get("question") or "").split())[:500]
answer = str(item.get("answer") or "").strip()[:5000]
if not question or not answer:
continue
options = [str(o)[:200] for o in item.get("options") or [] if isinstance(o, str)][:300]
kind = "written" if item.get("kind") == "written" else "fact"
out.append({"question": question, "answer": answer, "kind": kind, "options": options})
return out[:_MAX_REVIEW_ANSWERS]


async def _fill_with_agent(llm, browser_session, sensitive_data: dict | None, answers: list[dict],
secret: str = "") -> tuple[bool, str]:
"""Run a short follow-up agent on the paused form that puts the review's answers in. Like the submit
agent it gets the password-scoped tools, plus fill_answer for the written answers."""
numbered = {n: {**a, "source": "saved"} for n, a in enumerate(answers, 1)}
task = _FILL_TASK + "Answers:\n" + "\n".join(_answer_line(n, a) for n, a in numbered.items())
tools = _secret_scoped_tools()
_add_fill_answer(tools, browser_session, numbered)
try:
agent = Agent(task=task, llm=llm, browser_session=browser_session, use_vision=False, use_judge=False,
directly_open_url=False, tools=tools, sensitive_data=sensitive_data)
history = await agent.run(max_steps=min(4 + 2 * len(answers), 16))
return bool(history.is_successful()), _redact(history.final_result() or "", secret)[:300]
except Exception as exc:
error = _redact(str(exc), secret)
logger.warning("review fill agent failed: %s", error)
return False, error[:300]


async def _submit_with_agent(llm, browser_session, sensitive_data: dict | None,
secret: str = "") -> tuple[bool, str]:
"""Run a tiny follow-up agent on the same (kept-alive) browser to click the final submit.
Expand Down Expand Up @@ -813,6 +878,31 @@ def _answer_line(n: int, result: dict) -> str:
return f'{n}. "{question}": {verb} "{result["answer"]}"'


def _add_fill_answer(tools, browser_session, numbered: dict[int, dict]) -> None:
"""Register fill_answer, which pastes answer `numbered[n]` whole into a text box."""
@tools.action(
"Put a written answer into its text box in one step: give the box's index and the answer's number. It "
"replaces whatever the box holds.",
param_model=FillAnswerParams)
async def fill_answer(params: FillAnswerParams) -> ActionResult:
result = numbered.get(params.answer)
if not result or not result.get("answer"):
return ActionResult(error=f"There's no answer numbered {params.answer} to fill in.")
node = await browser_session.get_element_by_index(params.index)
if node is None:
return ActionResult(error=f"Element index {params.index} isn't available - the page may have changed.")
text = result["answer"]
try:
held = await _paste_text(browser_session, node, text)
except Exception as exc:
return ActionResult(error=f"Couldn't fill element {params.index}: {type(exc).__name__}: {exc}"[:300])
if " ".join(held.split()) != " ".join(text.split()):
return ActionResult(error=f"Filled, but element {params.index} now holds {len(held)} characters, not "
f"the answer's {len(text)}: check it is the box for that question.")
done = f"Filled answer {params.answer} ({len(text)} characters) into element {params.index}."
return ActionResult(extracted_content=done, long_term_memory=done)


def _apply_tools(cover_letter: str, browser_session, answerer: Answerer | None = None,
asked: dict[str, dict] | None = None) -> "Tools":
"""The fill agent's tools: the password-scoped set, answer_questions and fill_answer (recording what
Expand Down Expand Up @@ -846,28 +936,7 @@ async def answer_questions(params: AnswerQuestionsParams) -> ActionResult:
text = "Answers:\n" + "\n".join(lines)
return ActionResult(extracted_content=text, long_term_memory=text)

@tools.action(
"Put a written answer from answer_questions into its text box in one step: give the box's index and the "
"answer's number. It replaces whatever the box holds.",
param_model=FillAnswerParams)
async def fill_answer(params: FillAnswerParams) -> ActionResult:
result = numbered.get(params.answer)
if not result or not result.get("answer"):
return ActionResult(error=f"There's no answer numbered {params.answer} to fill in.")
node = await browser_session.get_element_by_index(params.index)
if node is None:
return ActionResult(error=f"Element index {params.index} isn't available - the page may have changed.")
text = result["answer"]
try:
held = await _paste_text(browser_session, node, text)
except Exception as exc:
return ActionResult(error=f"Couldn't fill element {params.index}: {type(exc).__name__}: {exc}"[:300])
if " ".join(held.split()) != " ".join(text.split()):
return ActionResult(error=f"Filled, but element {params.index} now holds {len(held)} characters, not "
f"the answer's {len(text)}: check it is the box for that question.")
done = f"Filled answer {params.answer} ({len(text)} characters) into element {params.index}."
return ActionResult(extracted_content=done, long_term_memory=done)

_add_fill_answer(tools, browser_session, numbered)
if not cover_letter.strip():
return tools

Expand Down Expand Up @@ -1111,10 +1180,32 @@ async def on_step(browser_state, agent_output, step_number):
"blocked": False, "reason": None, "done": True}
return

summary = notes
empty = await _empty_required_fields(browser_session)
questions = list(asked.values())
left_blank = [q["question"] for q in questions if q["source"] == "needs_you"]
notes = _with_empty_fields(notes, empty, left_blank)[:_MAX_NOTES_CHARS]
notes = _with_empty_fields(summary, empty, left_blank)[:_MAX_NOTES_CHARS]

async def put_answers_on_form(answers: list[dict]) -> dict:
ok, detail = await _fill_with_agent(llm, browser_session, sensitive_data, answers, job_password)
for a in answers:
key = _question_key(a["question"])
asked[key] = {**asked.get(key, {"bank_id": ""}), "question": asked.get(key, a)["question"],
"options": a["options"], "kind": a["kind"], "source": "saved", "answer": a["answer"],
"because": "Your answer in this review", "flags": []}
questions = list(asked.values())
blank = [q["question"] for q in questions if q["source"] == "needs_you"]
still_empty = await _empty_required_fields(browser_session)
count = f"{len(answers)} answer{'' if len(answers) == 1 else 's'}"
if ok:
action = f"Filled in your {count} — check them on the page before you submit"
else:
action = f"Couldn't put every answer on the form{': ' + detail if detail else ''} — check the page"
# The first pass's summary still lists these questions as needing you, so it gives way to what
# this pass did.
done = f"From your review: {detail}" if detail else ""
return {"action": action[:300], "questions": questions,
"notes": _with_empty_fields(done, still_empty, blank)[:_MAX_NOTES_CHARS]}
see_notes = " — see the agent's notes" if notes else ""
if agent_error:
intro = f"The agent hit a problem — handing control to you ({agent_error})"
Expand All @@ -1137,7 +1228,7 @@ async def on_step(browser_state, agent_output, step_number):
async for step in _interactive_session(
browser_session, instruction_queue, intro, reason,
on_submit=lambda: _submit_with_agent(llm, browser_session, sensitive_data, job_password),
notes=notes, questions=questions,
notes=notes, questions=questions, on_answers=put_answers_on_form,
):
yield step
if step.get("done"):
Expand Down
2 changes: 1 addition & 1 deletion api/evals/test_answers_eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def _entry(n, question, answer, kind="fact", company=""):
("What is your nationality?", ["British", "Irish", "Other"], {"British"}),
("What is your notice period?", [], {"2 weeks"}),
("What are your salary expectations?", [], {"£31,500"}),
("Expected salary band", ["£25,000-£30,000", "£30,000-£35,000", "£35,000+"], {"£30,000-£35,000", NEEDS}),
("Expected salary band", ["£25,000-£30,000", "£30,000-£35,000", "£35,000+"], {"£30,000-£35,000"}),
("Gender", ["Male", "Female", "Non-binary", "Prefer not to say"], {"Female"}),
("Do you consider yourself to have a disability?", ["Yes", "No", "Prefer not to say"], {"Prefer not to say"}),
("Are you 18 years of age or older?", YN, {"Yes"}),
Expand Down
Loading
Loading