DriptoBhattacharyya Claude Opus 4.8 commited on
Commit
6fe947e
·
1 Parent(s): 37bc9aa

Slim graph + cap tool rounds for gateway latency; pin gpt-oss-120b

Browse files

Drop planner LLM (always probe for file), merge evidence into solver, make formatter pure-Python: ~8-12 calls/question -> ~3-6. Add max_tool_rounds cap so research-heavy questions terminate fast (no more 300s timeouts). Pin openai/gpt-oss-120b on the gateway (fast + reliable tool/structured); force function_calling for structured output since routed models ignore json_schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

gaia_agent/config.py CHANGED
@@ -35,7 +35,9 @@ class Settings(BaseSettings):
35
  # --- OpenAI-compatible gateway (freellmapi) ---
36
  openai_compatible_base_url: str = "" # e.g. https://my-gateway.example.com/v1
37
  openai_compatible_api_key: str = ""
38
- openai_compatible_model: str = "auto"
 
 
39
  groq_vision_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
40
  groq_whisper_model: str = "whisper-large-v3"
41
 
@@ -46,6 +48,9 @@ class Settings(BaseSettings):
46
  # --- API + control knobs ---
47
  gaia_api_url: str = "https://agents-course-unit4-scoring.hf.space"
48
  max_judge_retries: int = 1
 
 
 
49
  recursion_limit: int = 40
50
  question_timeout: int = 300
51
 
 
35
  # --- OpenAI-compatible gateway (freellmapi) ---
36
  openai_compatible_base_url: str = "" # e.g. https://my-gateway.example.com/v1
37
  openai_compatible_api_key: str = ""
38
+ # Pin a model that reliably does tool calls + function-calling structured output.
39
+ # "auto"/Balanced routing bounces to models that don't, breaking the graph.
40
+ openai_compatible_model: str = "openai/gpt-oss-120b"
41
  groq_vision_model: str = "meta-llama/llama-4-scout-17b-16e-instruct"
42
  groq_whisper_model: str = "whisper-large-v3"
43
 
 
48
  # --- API + control knobs ---
49
  gaia_api_url: str = "https://agents-course-unit4-scoring.hf.space"
50
  max_judge_retries: int = 1
51
+ # Hard cap on research tool-calling rounds; after this, the model must answer
52
+ # without tools. Bounds worst-case latency on research-heavy questions.
53
+ max_tool_rounds: int = 3
54
  recursion_limit: int = 40
55
  question_timeout: int = 300
56
 
gaia_agent/graph.py CHANGED
@@ -10,14 +10,11 @@ from langgraph.graph import END, START, StateGraph
10
  from langgraph.prebuilt import ToolNode
11
 
12
  from gaia_agent.nodes import (
13
- evidence,
14
  formatter,
15
  ingest_file,
16
  judge,
17
- planner,
18
  research,
19
  route_after_judge,
20
- route_after_planner,
21
  route_after_research,
22
  solver,
23
  )
@@ -29,25 +26,19 @@ def build_graph():
29
  """Build and compile the GAIA agent graph."""
30
  g = StateGraph(GraphState)
31
 
32
- g.add_node("planner", planner)
33
  g.add_node("ingest_file", ingest_file)
34
  g.add_node("research", research)
35
  g.add_node("tools", ToolNode(RESEARCH_TOOLS))
36
- g.add_node("evidence", evidence)
37
  g.add_node("solver", solver)
38
  g.add_node("judge", judge)
39
  g.add_node("formatter", formatter)
40
 
41
- g.add_edge(START, "planner")
42
- g.add_conditional_edges(
43
- "planner", route_after_planner, {"ingest_file": "ingest_file", "research": "research"}
44
- )
45
  g.add_edge("ingest_file", "research")
46
  g.add_conditional_edges(
47
- "research", route_after_research, {"tools": "tools", "evidence": "evidence"}
48
  )
49
  g.add_edge("tools", "research")
50
- g.add_edge("evidence", "solver")
51
  g.add_edge("solver", "judge")
52
  g.add_conditional_edges(
53
  "judge", route_after_judge, {"research": "research", "formatter": "formatter"}
 
10
  from langgraph.prebuilt import ToolNode
11
 
12
  from gaia_agent.nodes import (
 
13
  formatter,
14
  ingest_file,
15
  judge,
 
16
  research,
17
  route_after_judge,
 
18
  route_after_research,
19
  solver,
20
  )
 
26
  """Build and compile the GAIA agent graph."""
27
  g = StateGraph(GraphState)
28
 
 
29
  g.add_node("ingest_file", ingest_file)
30
  g.add_node("research", research)
31
  g.add_node("tools", ToolNode(RESEARCH_TOOLS))
 
32
  g.add_node("solver", solver)
33
  g.add_node("judge", judge)
34
  g.add_node("formatter", formatter)
35
 
36
+ g.add_edge(START, "ingest_file")
 
 
 
37
  g.add_edge("ingest_file", "research")
38
  g.add_conditional_edges(
39
+ "research", route_after_research, {"tools": "tools", "solver": "solver"}
40
  )
41
  g.add_edge("tools", "research")
 
42
  g.add_edge("solver", "judge")
43
  g.add_conditional_edges(
44
  "judge", route_after_judge, {"research": "research", "formatter": "formatter"}
gaia_agent/llm.py CHANGED
@@ -72,6 +72,19 @@ def get_text_llm(temperature: float = 0.0):
72
  )
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  @lru_cache(maxsize=1)
76
  def get_vision_llm():
77
  """Return the Groq multimodal model used for image understanding."""
 
72
  )
73
 
74
 
75
+ def get_structured_llm(schema, temperature: float = 0.0):
76
+ """Return an LLM that emits an instance of ``schema`` (Pydantic).
77
+
78
+ On the openai_compatible gateway the routed model often ignores json_schema
79
+ response_format and returns prose, so we force tool/function-calling-based
80
+ structured output (which the gateway models do support).
81
+ """
82
+ llm = get_text_llm(temperature)
83
+ if get_settings().llm_provider.lower() == "openai_compatible":
84
+ return llm.with_structured_output(schema, method="function_calling")
85
+ return llm.with_structured_output(schema)
86
+
87
+
88
  @lru_cache(maxsize=1)
89
  def get_vision_llm():
90
  """Return the Groq multimodal model used for image understanding."""
gaia_agent/nodes.py CHANGED
@@ -6,26 +6,22 @@ Flow: planner -> [ingest_file] -> research <-> tools -> evidence -> solver
6
 
7
  from __future__ import annotations
8
 
 
 
9
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
10
 
11
  from gaia_agent.config import get_settings
12
- from gaia_agent.llm import get_text_llm
13
  from gaia_agent.prompts import (
14
- EVIDENCE_PROMPT,
15
- FORMATTER_PROMPT,
16
  GAIA_RULES,
17
  JUDGE_PROMPT,
18
- PLANNER_PROMPT,
19
  RESEARCH_PROMPT,
20
  SOLVER_PROMPT,
21
  )
22
  from gaia_agent.schemas import (
23
  Candidate,
24
- Evidence,
25
  FileExtract,
26
- FinalAnswer,
27
  JudgeVerdict,
28
- Plan,
29
  )
30
  from gaia_agent.state import GraphState
31
  from gaia_agent.tools import (
@@ -39,39 +35,20 @@ from gaia_agent.tools import (
39
  )
40
 
41
  # --------------------------------------------------------------------------- #
42
- # Planner
43
- # --------------------------------------------------------------------------- #
44
-
45
-
46
- def planner(state: GraphState) -> dict:
47
- """Produce a structured Plan and initialise loop bookkeeping."""
48
- try:
49
- llm = get_text_llm().with_structured_output(Plan)
50
- plan = llm.invoke(
51
- PLANNER_PROMPT.format(question=state["question"], task_id=state.get("task_id", ""))
52
- )
53
- except Exception: # noqa: BLE001
54
- plan = Plan(needs_file=False, reasoning="planner-fallback")
55
- return {"plan": plan, "attempts": 0, "context_notes": ""}
56
-
57
-
58
- def route_after_planner(state: GraphState) -> str:
59
- """Go fetch the file only if the plan says one is needed."""
60
- plan = state.get("plan")
61
- return "ingest_file" if (plan and plan.needs_file) else "research"
62
-
63
-
64
- # --------------------------------------------------------------------------- #
65
- # File ingestion
66
  # --------------------------------------------------------------------------- #
67
 
68
 
69
  def ingest_file(state: GraphState) -> dict:
70
- """Download and extract the task's attached file into context_notes."""
 
 
 
 
71
  task_id = state.get("task_id", "")
72
  path = fetch_task_file(task_id)
73
  if not path:
74
- return {"file_extract": None}
75
 
76
  kind = classify_file(path)
77
  question = state["question"]
@@ -90,8 +67,8 @@ def ingest_file(state: GraphState) -> dict:
90
  extracted = f"File extraction failed: {exc}"
91
 
92
  fe = FileExtract(kind=kind, summary=f"Attached {kind} file.", extracted=str(extracted)[:8000])
93
- notes = (state.get("context_notes", "") + f"\n[FILE:{kind}]\n{fe.extracted}").strip()
94
- return {"file_extract": fe, "context_notes": notes}
95
 
96
 
97
  # --------------------------------------------------------------------------- #
@@ -122,8 +99,16 @@ def _invoke_with_tools(llm, msgs: list):
122
 
123
 
124
  def research(state: GraphState) -> dict:
125
- """Run one step of the research tool-calling loop."""
126
- llm = get_text_llm().bind_tools(RESEARCH_TOOLS)
 
 
 
 
 
 
 
 
127
  if not state.get("messages"):
128
  system = RESEARCH_PROMPT.format(
129
  rules=GAIA_RULES,
@@ -138,47 +123,34 @@ def research(state: GraphState) -> dict:
138
  ),
139
  ]
140
  ai = _invoke_with_tools(llm, seed)
141
- return {"messages": seed + [ai]}
142
  ai = _invoke_with_tools(llm, state["messages"])
143
- return {"messages": [ai]}
144
 
145
 
146
  def route_after_research(state: GraphState) -> str:
147
- """Route to tools if the model requested any, else summarise evidence."""
148
  last = state["messages"][-1]
149
  if getattr(last, "tool_calls", None):
150
  return "tools"
151
- return "evidence"
152
 
153
 
154
  # --------------------------------------------------------------------------- #
155
- # Evidence / Solver / Judge / Formatter
156
  # --------------------------------------------------------------------------- #
157
 
158
 
159
- def evidence(state: GraphState) -> dict:
160
- """Distil the research conversation into structured Evidence."""
161
- try:
162
- llm = get_text_llm().with_structured_output(Evidence)
163
- ev = llm.invoke([SystemMessage(EVIDENCE_PROMPT), *state.get("messages", [])])
164
- except Exception: # noqa: BLE001
165
- ev = Evidence(findings=[], sources=[], confidence=0.0)
166
- return {"evidence": ev}
167
-
168
-
169
  def solver(state: GraphState) -> dict:
170
- """Synthesize evidence + context into a single Candidate answer."""
171
- ev = state.get("evidence")
172
  try:
173
- llm = get_text_llm().with_structured_output(Candidate)
174
- cand = llm.invoke(
175
- SOLVER_PROMPT.format(
176
- rules=GAIA_RULES,
177
- question=state["question"],
178
- evidence=ev.model_dump() if ev else "{}",
179
- context_notes=state.get("context_notes", "") or "(none)",
180
- )
181
  )
 
182
  except Exception: # noqa: BLE001
183
  cand = Candidate(answer="", justification="solver-fallback")
184
  return {"candidate": cand}
@@ -187,14 +159,13 @@ def solver(state: GraphState) -> dict:
187
  def judge(state: GraphState) -> dict:
188
  """LLM-as-judge: PASS, or REVISE with feedback fed back into research."""
189
  cand = state.get("candidate")
190
- ev = state.get("evidence")
191
  try:
192
- llm = get_text_llm().with_structured_output(JudgeVerdict)
193
  verdict = llm.invoke(
194
  JUDGE_PROMPT.format(
195
  rules=GAIA_RULES,
196
  question=state["question"],
197
- evidence=ev.model_dump() if ev else "{}",
198
  candidate=cand.answer if cand else "",
199
  )
200
  )
@@ -227,17 +198,13 @@ def route_after_judge(state: GraphState) -> str:
227
 
228
 
229
  def formatter(state: GraphState) -> dict:
230
- """Render the candidate into the bare exact-match answer string."""
 
 
 
 
231
  cand = state.get("candidate")
232
- candidate_text = cand.answer if cand else ""
233
- try:
234
- llm = get_text_llm().with_structured_output(FinalAnswer)
235
- final = llm.invoke(
236
- FORMATTER_PROMPT.format(
237
- rules=GAIA_RULES, question=state["question"], candidate=candidate_text
238
- )
239
- )
240
- answer = final.answer
241
- except Exception: # noqa: BLE001
242
- answer = candidate_text
243
- return {"final_answer": (answer or candidate_text or "").strip()}
 
6
 
7
  from __future__ import annotations
8
 
9
+ import re
10
+
11
  from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
12
 
13
  from gaia_agent.config import get_settings
14
+ from gaia_agent.llm import get_structured_llm, get_text_llm
15
  from gaia_agent.prompts import (
 
 
16
  GAIA_RULES,
17
  JUDGE_PROMPT,
 
18
  RESEARCH_PROMPT,
19
  SOLVER_PROMPT,
20
  )
21
  from gaia_agent.schemas import (
22
  Candidate,
 
23
  FileExtract,
 
24
  JudgeVerdict,
 
25
  )
26
  from gaia_agent.state import GraphState
27
  from gaia_agent.tools import (
 
35
  )
36
 
37
  # --------------------------------------------------------------------------- #
38
+ # File ingestion (entry node: always probe for an attached file)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # --------------------------------------------------------------------------- #
40
 
41
 
42
  def ingest_file(state: GraphState) -> dict:
43
+ """Download and extract the task's attached file (if any) into context_notes.
44
+
45
+ Always attempts the fetch (cheap HTTP) instead of asking an LLM whether a file
46
+ is needed -- saves a call per question and never misses an attachment.
47
+ """
48
  task_id = state.get("task_id", "")
49
  path = fetch_task_file(task_id)
50
  if not path:
51
+ return {"file_extract": None, "attempts": 0, "context_notes": ""}
52
 
53
  kind = classify_file(path)
54
  question = state["question"]
 
67
  extracted = f"File extraction failed: {exc}"
68
 
69
  fe = FileExtract(kind=kind, summary=f"Attached {kind} file.", extracted=str(extracted)[:8000])
70
+ notes = f"[FILE:{kind}]\n{fe.extracted}".strip()
71
+ return {"file_extract": fe, "context_notes": notes, "attempts": 0}
72
 
73
 
74
  # --------------------------------------------------------------------------- #
 
99
 
100
 
101
  def research(state: GraphState) -> dict:
102
+ """Run one step of the research tool-calling loop.
103
+
104
+ Tools are bound only while under the max_tool_rounds cap; past it, the model is
105
+ called without tools so it must answer, guaranteeing the loop terminates fast.
106
+ """
107
+ rounds = state.get("tool_rounds", 0)
108
+ base = get_text_llm()
109
+ use_tools = rounds < get_settings().max_tool_rounds
110
+ llm = base.bind_tools(RESEARCH_TOOLS) if use_tools else base
111
+
112
  if not state.get("messages"):
113
  system = RESEARCH_PROMPT.format(
114
  rules=GAIA_RULES,
 
123
  ),
124
  ]
125
  ai = _invoke_with_tools(llm, seed)
126
+ return {"messages": seed + [ai], "tool_rounds": rounds + 1}
127
  ai = _invoke_with_tools(llm, state["messages"])
128
+ return {"messages": [ai], "tool_rounds": rounds + 1}
129
 
130
 
131
  def route_after_research(state: GraphState) -> str:
132
+ """Route to tools if the model requested any, else go straight to the solver."""
133
  last = state["messages"][-1]
134
  if getattr(last, "tool_calls", None):
135
  return "tools"
136
+ return "solver"
137
 
138
 
139
  # --------------------------------------------------------------------------- #
140
+ # Solver / Judge / Formatter
141
  # --------------------------------------------------------------------------- #
142
 
143
 
 
 
 
 
 
 
 
 
 
 
144
  def solver(state: GraphState) -> dict:
145
+ """Synthesize the research conversation + context into one Candidate answer."""
 
146
  try:
147
+ llm = get_structured_llm(Candidate)
148
+ system = SOLVER_PROMPT.format(
149
+ rules=GAIA_RULES,
150
+ question=state["question"],
151
+ context_notes=state.get("context_notes", "") or "(none)",
 
 
 
152
  )
153
+ cand = llm.invoke([SystemMessage(system), *state.get("messages", [])])
154
  except Exception: # noqa: BLE001
155
  cand = Candidate(answer="", justification="solver-fallback")
156
  return {"candidate": cand}
 
159
  def judge(state: GraphState) -> dict:
160
  """LLM-as-judge: PASS, or REVISE with feedback fed back into research."""
161
  cand = state.get("candidate")
 
162
  try:
163
+ llm = get_structured_llm(JudgeVerdict)
164
  verdict = llm.invoke(
165
  JUDGE_PROMPT.format(
166
  rules=GAIA_RULES,
167
  question=state["question"],
168
+ context_notes=state.get("context_notes", "") or "(none)",
169
  candidate=cand.answer if cand else "",
170
  )
171
  )
 
198
 
199
 
200
  def formatter(state: GraphState) -> dict:
201
+ """Pure-Python scrub of the candidate into the bare exact-match string (no LLM).
202
+
203
+ The solver already formats per GAIA rules; this just strips stray prefixes,
204
+ quotes, and whitespace.
205
+ """
206
  cand = state.get("candidate")
207
+ text = (cand.answer if cand else "") or ""
208
+ text = re.sub(r"(?is)^\s*(final answer|answer)\s*:?\s*", "", text).strip()
209
+ text = text.strip().strip('"').strip("'").strip()
210
+ return {"final_answer": text}
 
 
 
 
 
 
 
 
gaia_agent/prompts.py CHANGED
@@ -50,17 +50,15 @@ their sources, and your confidence (0-1) that they pin down the answer.
50
  """
51
 
52
  SOLVER_PROMPT = """\
53
- You are the SOLVER. Using the evidence and context below, produce ONE candidate answer.
54
- Apply the GAIA formatting rules to your candidate, but you may keep a short justification.
 
55
 
56
  {rules}
57
 
58
  Question:
59
  {question}
60
 
61
- Evidence:
62
- {evidence}
63
-
64
  Context notes:
65
  {context_notes}
66
  """
@@ -75,8 +73,8 @@ rules. Be strict: a near-miss in format is a REVISE.
75
  Question:
76
  {question}
77
 
78
- Evidence:
79
- {evidence}
80
 
81
  Candidate answer:
82
  {candidate}
 
50
  """
51
 
52
  SOLVER_PROMPT = """\
53
+ You are the SOLVER. Using the research conversation above and the context below,
54
+ produce ONE candidate answer. The `answer` field MUST already obey the GAIA
55
+ exact-match formatting rules (bare answer, no prose, no "FINAL ANSWER").
56
 
57
  {rules}
58
 
59
  Question:
60
  {question}
61
 
 
 
 
62
  Context notes:
63
  {context_notes}
64
  """
 
73
  Question:
74
  {question}
75
 
76
+ Context notes:
77
+ {context_notes}
78
 
79
  Candidate answer:
80
  {candidate}
gaia_agent/state.py CHANGED
@@ -42,6 +42,8 @@ class GraphState(TypedDict, total=False):
42
 
43
  # Eval-loop bookkeeping
44
  attempts: int
 
 
45
 
46
  # Output
47
  final_answer: str
 
42
 
43
  # Eval-loop bookkeeping
44
  attempts: int
45
+ # Research tool-calling rounds used (capped by max_tool_rounds)
46
+ tool_rounds: int
47
 
48
  # Output
49
  final_answer: str
tests/test_graph.py CHANGED
@@ -6,10 +6,9 @@ from unittest.mock import MagicMock, patch
6
  from gaia_agent.nodes import (
7
  judge,
8
  route_after_judge,
9
- route_after_planner,
10
  route_after_research,
11
  )
12
- from gaia_agent.schemas import JudgeVerdict, Plan
13
 
14
 
15
  def test_graph_compiles():
@@ -17,19 +16,14 @@ def test_graph_compiles():
17
 
18
  assert graph.name == "GAIA Agent"
19
  nodes = set(graph.get_graph().nodes.keys())
20
- assert {"planner", "research", "tools", "judge", "formatter"} <= nodes
21
 
22
 
23
- def test_route_after_planner():
24
- assert route_after_planner({"plan": Plan(needs_file=True)}) == "ingest_file"
25
- assert route_after_planner({"plan": Plan(needs_file=False)}) == "research"
26
-
27
-
28
- def test_route_after_research_tools_vs_evidence():
29
  with_calls = SimpleNamespace(tool_calls=[{"name": "tavily_search"}])
30
  without = SimpleNamespace(tool_calls=[])
31
  assert route_after_research({"messages": [with_calls]}) == "tools"
32
- assert route_after_research({"messages": [without]}) == "evidence"
33
 
34
 
35
  def test_route_after_judge_pass():
@@ -38,23 +32,24 @@ def test_route_after_judge_pass():
38
 
39
 
40
  def test_route_after_judge_revise_within_budget():
41
- # max_judge_retries default is 2; one attempt used -> still loop back.
42
- state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 1}
43
- assert route_after_judge(state) == "research"
44
 
45
 
46
  def test_route_after_judge_revise_budget_exhausted():
47
- state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 2}
48
- assert route_after_judge(state) == "formatter"
 
49
 
50
 
51
  def test_judge_increments_attempts_on_revise():
52
- fake_llm = MagicMock()
53
- fake_llm.with_structured_output.return_value.invoke.return_value = JudgeVerdict(
54
  verdict="REVISE", feedback="wrong format", missing=["units"]
55
  )
56
- with patch("gaia_agent.nodes.get_text_llm", return_value=fake_llm):
57
- out = judge({"question": "q", "candidate": None, "evidence": None, "attempts": 0})
58
  assert out["attempts"] == 1
59
  assert out["verdict"].verdict == "REVISE"
60
  assert "messages" in out # feedback injected back into the loop
 
6
  from gaia_agent.nodes import (
7
  judge,
8
  route_after_judge,
 
9
  route_after_research,
10
  )
11
+ from gaia_agent.schemas import JudgeVerdict
12
 
13
 
14
  def test_graph_compiles():
 
16
 
17
  assert graph.name == "GAIA Agent"
18
  nodes = set(graph.get_graph().nodes.keys())
19
+ assert {"ingest_file", "research", "tools", "solver", "judge", "formatter"} <= nodes
20
 
21
 
22
+ def test_route_after_research_tools_vs_solver():
 
 
 
 
 
23
  with_calls = SimpleNamespace(tool_calls=[{"name": "tavily_search"}])
24
  without = SimpleNamespace(tool_calls=[])
25
  assert route_after_research({"messages": [with_calls]}) == "tools"
26
+ assert route_after_research({"messages": [without]}) == "solver"
27
 
28
 
29
  def test_route_after_judge_pass():
 
32
 
33
 
34
  def test_route_after_judge_revise_within_budget():
35
+ state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 0}
36
+ with patch("gaia_agent.nodes.get_settings", return_value=SimpleNamespace(max_judge_retries=1)):
37
+ assert route_after_judge(state) == "research"
38
 
39
 
40
  def test_route_after_judge_revise_budget_exhausted():
41
+ state = {"verdict": JudgeVerdict(verdict="REVISE"), "attempts": 1}
42
+ with patch("gaia_agent.nodes.get_settings", return_value=SimpleNamespace(max_judge_retries=1)):
43
+ assert route_after_judge(state) == "formatter"
44
 
45
 
46
  def test_judge_increments_attempts_on_revise():
47
+ structured = MagicMock()
48
+ structured.invoke.return_value = JudgeVerdict(
49
  verdict="REVISE", feedback="wrong format", missing=["units"]
50
  )
51
+ with patch("gaia_agent.nodes.get_structured_llm", return_value=structured):
52
+ out = judge({"question": "q", "candidate": None, "attempts": 0})
53
  assert out["attempts"] == 1
54
  assert out["verdict"].verdict == "REVISE"
55
  assert "messages" in out # feedback injected back into the loop