JJoeven

Projects/ReAct Research Agent/Part 4

Citations, Max Steps, and Cannot-Answer

Validate citations against opened URLs, require evidence spans, and implement a first-class cannot-answer finish for missing facts.

This part is the difference between a search demo and a research product. Three rules, all enforced in code:

  1. Citations ⊆ opened URLs.
  2. Positive answers need at least one citation and a verbatim evidence span from an opened body (optional but we will implement a simple evidence in body check).
  3. Cannot-answer is a legal, tested outcome — including max-steps and empty search hits.

If you skip these, the agent will quote https://wiki.example/acme because it is in the prompt examples, without opening it.

Finish schema

text
{
  "answer": str,
  "citations": list[str],
  "cannot_answer": bool,
  "evidence": str   # substring copied from a page body; empty if cannot_answer
}

Validation function validate_finish(payload, opened, pages):

  • Types and keys.
  • If cannot_answer: citations must be empty; evidence empty; answer starts with cannot:.
  • If not cannot_answer: citations non-empty, each in opened, evidence non-empty, evidence is a substring of at least one opened page body (normalize whitespace).
  • Reject answers that mention years or names not in the concatenated opened bodies (lightweight overlap check). This is imperfect; it still kills the worst hallucinations.

Cannot-answer triggers

TriggerAgent behavior
Search hits emptyOpen nothing; finish cannot-answer
Opens succeed but fact missingDo not stitch a story; cannot-answer
open not_found for all attemptscannot-answer
max_stepscannot-answer with reason budget
Finish fails validationRetry finish once, then cannot-answer

The last row matters. If you only raise, the user sees a 500. Convert invalid finish into a protocol error observation: finish rejected: citation not opened.

Live Pythonpython
Output
Run to execute this in your browser. Nothing is sent to a server.

Step-by-step: wiring validation into the loop

  1. When the policy calls finish, run validate_finish.
  2. If it returns a string error, do not return to the user yet. Append {"role": "tool", "name": "finish", "content": {"error": code}}.
  3. Allow one repair turn. If the second finish is still invalid, return budget_finish()-style abstention with reason invalid_finish.
  4. Track opened only on successful opens.
  5. Pass bodies of opened pages, not the whole WEB, into the validator. Otherwise the agent could "cite" an opened URL while copying evidence from an unopened page that leaked into the prompt. Keep page bodies out of the system prompt except as observations.

Eval table (use in the next part too)

QuestionExpected
Who founded Acme Robotics?Ada Ortiz, cite wiki/acme
Who is the current CEO?Kenji Ito, must open news page (if snippet is short)
What is Ada's favorite food?cannot_answer
Invent a URL in citationsvalidate_finish fails

Max steps vs infinite curiosity

Set max_steps=6. A policy that searches the same query twice is wasting. In part 5 you can detect duplicate queries and inject you already searched that. For now, the cap is enough.

Tip:Store cannot_answer as a boolean in JSON, not as English "I cannot answer" only. Machines grade booleans.

Exercise

Write validate_finish tests for: empty citations on a positive answer; citation to an opened URL but evidence from a different unopened string; cannot-answer with a leftover citation. All three must fail. Then implement the one-repair-turn in your loop from part 3.

Check your understanding

When is a citation valid in this project?