File size: 14,244 Bytes
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459bdc8
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b720844
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b720844
 
 
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459bdc8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e94c043
 
459bdc8
e94c043
 
 
459bdc8
e94c043
 
 
 
 
 
459bdc8
 
 
e94c043
 
459bdc8
 
e94c043
 
 
 
 
459bdc8
 
e94c043
 
 
0d9f1df
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
459bdc8
0d9f1df
 
e94c043
 
 
 
0d9f1df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e94c043
459bdc8
 
e94c043
cadaa08
459bdc8
e94c043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"""ReAct agent loop over the uploaded-packet tools in :mod:`webapp.agent_tools`.

The model drives: each step it emits ONE JSON action β€” ``{"thought", "tool",
"args"}`` β€” we run the tool, append the observation to a scratchpad, and ask
again, until it emits the terminal ``final_answer`` tool (or a budget runs out).

Why a JSON-action ReAct loop rather than the OpenAI ``tools=`` function-calling
API: the shipped runtime is a local GGUF (Gemma) via llama.cpp, whose native
tool-calling is unreliable. A plain "emit one JSON object" protocol is robust on
local models and works identically against the remote vLLM endpoint we validate
against first β€” so the loop only needs a ``complete(prompt, system) -> str``
callable and stays backend-agnostic.

The loop is a generator of *frames* (``{"stage", ...}``) so the caller
(:func:`webapp.backend.agent_chat`) can stream the agent's thinking, tool calls,
tool results, and final answer to the React UI live.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from typing import Callable, Iterator

from webapp.agent_tools import (
    FINAL_ANSWER,
    ToolContext,
    observation_text,
    run_tool,
    tool_catalog,
)

# Turn budgets. Steps cap tool calls (and, on ZeroGPU, GPU-window seconds); the
# parse budget tolerates a few malformed JSON replies before we give up.
DEFAULT_MAX_STEPS = 6
_PARSE_RETRIES = 2
# How much prior conversation to carry (chars) β€” keeps the prompt inside an 8K ctx.
_HISTORY_CHARS = 4000


def _system_prompt(ctx: ToolContext) -> str:
    catalog = json.dumps(tool_catalog(), ensure_ascii=False, indent=2)
    return (
        "You are the Agenda Parser's research agent. You help the user understand a "
        "single uploaded public-meeting agenda packet (one compiled PDF: the agenda "
        "plus every item's backup documents).\n\n"
        "You work by calling tools, one at a time, and reasoning over what they return. "
        "Start with list_agenda_items to see the agenda and which items have backup "
        "pages.\n\n"
        "Available tools (JSON Schema):\n" + catalog + "\n\n"
        "PROTOCOL β€” follow exactly:\n"
        "- Respond with a SINGLE JSON object and nothing else (no prose, no code fence).\n"
        '- Shape: {"thought": "<one short sentence>", "tool": "<tool name>", "args": {<args>}}\n'
        "- Call exactly one tool per step. Read its result, then decide the next step.\n"
        "- Example step:\n"
        '  {"thought": "Find where the budget total is stated.", "tool": "find_text", '
        '"args": {"query": "$"}}\n'
        f'- When you can answer, call "{FINAL_ANSWER}" with an "answer" in markdown.\n\n'
        "CHOOSING A TOOL:\n"
        "- find_text β€” exact strings: dollar amounts, dates, names, acronyms, ordinance/"
        "statute numbers, \"Item 7\". Fast and literal.\n"
        "- search_packet β€” conceptual lookups (\"where is X discussed\"); ranks by meaning, "
        "so it can miss exact tokens that find_text would catch.\n"
        "- get_item_text β€” read the backup pages for a specific item; if has_more is true, "
        "call again with offset=next_offset to read the rest.\n"
        "- summarize / report β€” heavy: each is a multi-pass LLM run over the whole packet "
        "(slow). Use ONLY for whole-agenda summaries or thorough briefings, never for a "
        "single fact.\n\n"
        "RULES:\n"
        "- Cite the item number and page range you drew each fact from. Do not invent "
        "facts you did not see via a tool.\n"
        "- If list_agenda_items reports confidence \"poor\", the page ranges came from text, "
        "not bookmarks β€” verify with get_item_text or find_text before trusting them.\n"
        "- If a tool errors, change your args or switch tools; never repeat the identical "
        "failing call. If something fails twice, answer with what you have.\n"
        "- You have a limited number of steps. Be efficient and call "
        f'"{FINAL_ANSWER}" as soon as the question is answerable.'
    )


def _render_history(messages: list[dict]) -> str:
    """The prior conversation as a compact transcript (most recent kept)."""
    lines = []
    for m in messages:
        role = "User" if m.get("role") == "user" else "Assistant"
        content = (m.get("content") or "").strip()
        if content:
            lines.append(f"{role}: {content}")
    text = "\n".join(lines)
    if len(text) > _HISTORY_CHARS:
        text = "…" + text[-_HISTORY_CHARS:]
    return text or "(no prior messages)"


def _extract_json(text: str) -> dict | None:
    """Best-effort parse of a single JSON object from a model reply.

    Tolerates code fences and surrounding prose by falling back to the first
    balanced ``{...}`` span. Returns ``None`` if nothing parses.
    """
    s = (text or "").strip()
    if s.startswith("```"):
        s = s.split("```", 2)[1] if s.count("```") >= 2 else s.strip("`")
        if s.lstrip().lower().startswith("json"):
            s = s.lstrip()[4:]
    s = s.strip()
    try:
        obj = json.loads(s)
        return obj if isinstance(obj, dict) else None
    except (json.JSONDecodeError, ValueError):
        pass
    # Fall back: scan for the first balanced brace span.
    start = s.find("{")
    while start != -1:
        depth = 0
        for i in range(start, len(s)):
            if s[i] == "{":
                depth += 1
            elif s[i] == "}":
                depth -= 1
                if depth == 0:
                    try:
                        obj = json.loads(s[start : i + 1])
                        if isinstance(obj, dict):
                            return obj
                    except (json.JSONDecodeError, ValueError):
                        break
        start = s.find("{", start + 1)
    return None


def _result_summary(tool: str, result: dict) -> str:
    """A one-line, human-readable gist of a tool result for the live UI (the raw JSON
    stays available behind a disclosure). Best-effort β€” empty string when nothing fits."""
    if not isinstance(result, dict):
        return ""
    if result.get("error"):
        return f"error: {str(result['error'])[:80]}"
    if tool == "find_text" and isinstance(result.get("count"), int):
        n = result["count"]
        return f"{n} match{'' if n == 1 else 'es'}"
    if isinstance(result.get("count"), int):
        n = result["count"]
        noun = "item" if tool == "list_agenda_items" else "result"
        s = f"{n} {noun}{'' if n == 1 else 's'}"
        conf = result.get("confidence")
        return f"{s} Β· {conf}" if conf else s
    if isinstance(result.get("results"), list):
        n = len(result["results"])
        return f"{n} passage{'' if n == 1 else 's'}"
    if tool == "get_item_text":
        if result.get("sliced") and result.get("pages"):
            return f"pp. {result['pages']}"
        return str(result.get("method") or "full packet")
    if result.get("summary"):
        return "summary ready"
    if result.get("report"):
        return "report ready"
    if isinstance(result.get("items"), list):
        n = len(result["items"])
        return f"{n} item{'' if n == 1 else 's'}"
    return ""


@dataclass(frozen=True)
class Toolkit:
    """The agenda-specific pieces the otherwise-generic loop plugs in.

    Bundling these lets a second agent (e.g. the Cornell LII legal-research agent) reuse
    the whole ReAct loop with a different toolset by passing its own ``Toolkit`` β€” only the
    system prompt, tool dispatch, and result-summary differ; the protocol, JSON parsing,
    scratchpad, retries, and budget logic are shared.

    - ``system_prompt(ctx) -> str``: the agent's role + tool catalog + PROTOCOL.
    - ``run_tool(name, args, ctx) -> dict``: dispatch one tool call (errors as ``{"error"}``).
    - ``observation_text(result) -> str``: compact JSON of a result for the scratchpad.
    - ``result_summary(tool, result) -> str``: one-line human gist for the live UI.
    - ``final_answer``: the terminal pseudo-tool name that ends the turn.
    """

    system_prompt: Callable[..., str]
    run_tool: Callable[..., dict]
    observation_text: Callable[..., str]
    result_summary: Callable[..., str]
    final_answer: str


# The default toolkit β€” the uploaded-packet research agent (unchanged behavior).
AGENDA_TOOLKIT = Toolkit(
    system_prompt=_system_prompt,
    run_tool=run_tool,
    observation_text=observation_text,
    result_summary=_result_summary,
    final_answer=FINAL_ANSWER,
)


def agent_turn(
    messages: list[dict],
    ctx: object,
    complete: Callable[..., str],
    *,
    max_steps: int = DEFAULT_MAX_STEPS,
    toolkit: Toolkit | None = None,
) -> Iterator[dict]:
    """Run one user turn of the ReAct loop, yielding progress/result frames.

    Args:
        messages: the full chat so far (list of ``{"role", "content"}``), ending with
            the user's current message.
        ctx: the tool context the toolkit's tools operate on (packet ``ToolContext`` for
            the agenda agent, ``LiiContext`` for the LII agent) β€” passed straight through
            to ``toolkit.run_tool``.
        complete: ``complete(prompt, system=...) -> str`` the agent reasons with.
        max_steps: maximum tool calls before the loop force-stops.
        toolkit: which toolset/system-prompt to drive the loop with; defaults to the
            agenda packet agent (:data:`AGENDA_TOOLKIT`).

    Frames (``{"stage", ...}``):
        ``thinking`` (text, step) Β· ``tool_call`` (tool, args, step) Β·
        ``tool_result`` (tool, result, step) Β· ``answer`` (text) Β· ``error`` (text).
    """
    tk = toolkit or AGENDA_TOOLKIT
    system = tk.system_prompt(ctx)
    history = _render_history(messages)
    scratchpad: list[str] = []
    parse_fails = 0
    seen_actions: set[str] = set()  # canonical (tool, args) already run this turn

    for step in range(1, max_steps + 1):
        pad = "\n".join(scratchpad) if scratchpad else "(empty β€” no tool calls yet)"
        prompt = (
            f"Conversation so far:\n{history}\n\n"
            f"Your scratchpad this turn (tool calls and their results):\n{pad}\n\n"
            "Respond with the next JSON action."
        )
        try:
            raw = complete(prompt, system=system)
        except Exception as e:  # noqa: BLE001
            yield {"stage": "error", "text": f"Model call failed: {type(e).__name__}: {e}"}
            return

        action = _extract_json(raw)
        if action is None or "tool" not in action:
            parse_fails += 1
            if parse_fails > _PARSE_RETRIES:
                # Out of retries β€” surface whatever the model said as the answer.
                yield {"stage": "answer", "text": (raw or "").strip() or
                       "I couldn't formulate a structured answer."}
                return
            # Tell the UI we're nudging the model back to valid JSON, so the work area
            # isn't silent during a retry.
            yield {"stage": "notice", "text": "Re-reading the request…", "step": step}
            scratchpad.append(
                'SYSTEM: Your last reply was not a single valid JSON action. '
                'Reply with ONLY {"thought":..., "tool":..., "args":{...}}.'
            )
            continue

        thought = str(action.get("thought") or "").strip()
        tool = str(action.get("tool") or "").strip()
        targs = action.get("args")
        if not isinstance(targs, dict):
            targs = {}

        if tool == tk.final_answer:
            if thought:
                yield {"stage": "thinking", "text": thought, "step": step}
            answer = str(targs.get("answer") or action.get("answer") or "").strip()
            yield {"stage": "answer", "text": answer or "(no answer)"}
            return

        # Skip an identical repeated tool call β€” a known small-model failure mode the
        # PROTOCOL warns against (it re-emits the same action despite the scratchpad). Don't
        # re-run it or stream a duplicate step to the UI; nudge the model to use the prior
        # result, vary the call, or finish, and move on to the next step.
        action_key = json.dumps({"tool": tool, "args": targs}, sort_keys=True, ensure_ascii=False)
        if action_key in seen_actions:
            scratchpad.append(
                f"SYSTEM: You already ran {tool} with those exact arguments this turn; its "
                "result is in the scratchpad above. Do NOT repeat it β€” use that result, try "
                f'different arguments or another tool, or call "{tk.final_answer}".'
            )
            continue
        seen_actions.add(action_key)

        if thought:
            yield {"stage": "thinking", "text": thought, "step": step}
        yield {"stage": "tool_call", "tool": tool, "args": targs, "step": step}
        result = tk.run_tool(tool, targs, ctx)
        obs = tk.observation_text(result)
        yield {
            "stage": "tool_result", "tool": tool, "args": targs, "result": obs,
            "summary": tk.result_summary(tool, result), "step": step,
        }
        scratchpad.append(
            f'ACTION: {json.dumps({"tool": tool, "args": targs}, ensure_ascii=False)}\n'
            f"OBSERVATION: {obs}"
        )

    # Budget exhausted: ask for a final answer from what we've gathered.
    pad = "\n".join(scratchpad) if scratchpad else "(no tool results)"
    closing = (
        f"Conversation so far:\n{history}\n\n"
        f"Your scratchpad this turn:\n{pad}\n\n"
        "You have reached the step limit. Write your best final answer for the user now, "
        "in markdown, using only what the tools returned above."
    )
    try:
        final = complete(closing, system=system)
    except Exception as e:  # noqa: BLE001
        yield {"stage": "error", "text": f"Model call failed: {type(e).__name__}: {e}"}
        return
    # The closing prompt asks for prose, but tolerate a stray JSON final_answer.
    parsed = _extract_json(final)
    if parsed and parsed.get("tool") == FINAL_ANSWER:
        final = str((parsed.get("args") or {}).get("answer") or final)
    yield {"stage": "answer", "text": (final or "").strip() or "(no answer)"}