File size: 8,545 Bytes
74239a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""The agentic loop: give the model a workspace + tools and let it work.

Unlike a single LLM call, the model here explores the SAP with tools (grep /
read in chunks), writes output.json and output.R into the workspace, and can run
the R script to verify it before finishing.
"""

from __future__ import annotations

import json
from typing import Any, Callable, Dict, List, Optional

from lib.agent import SYSTEM_PROMPT, provider_for
from lib.tools import Workspace, anthropic_tools, openai_tools

# Appended to the task so the model knows it is working in a workspace.
AGENT_TASK_TEMPLATE = """Your workspace contains the trial's source document as a text file with page
markers (lines like `===== Page 12 =====`). The document is large — do NOT try to
read it all at once. Use `grep` to locate the relevant sections, then `read_file`
around those line numbers. Start by calling `list_files`.

Answer these evaluation questions:

```json
{prompt_block}
```

Then write two files into the workspace:

1. `output.json` — a JSON object with a single top-level key `"output"` whose
   value is the prompt block above with every `null` replaced by your extracted
   or derived result. Do not add, remove, rename, or modify any other fields.
2. `output.R` — R code implementing the calculations for every
   "derivation_required" question. For each one it must print (1) the source
   inputs, (2) the calculation method and formula applied, and (3) the final
   calculated value.

After writing `output.R`, run it with `run_r` and fix it until it executes
without errors and prints values consistent with `output.json`. If Rscript is
unavailable, say so and continue.

When both files are written (and R verified if possible), reply with a short
summary of what you found and any values that were absent from the document.
"""


def _max_tokens(effort: Optional[str]) -> int:
    # Thinking tokens count against max_tokens, so give high effort room.
    return 64000 if effort in ("xhigh", "max") else 16000


def run_agent(
    model: str,
    workspace: Workspace,
    prompt_block: Dict[str, Any],
    api_key: Optional[str] = None,
    effort: Optional[str] = None,
    max_iterations: int = 30,
    on_event: Optional[Callable[[Dict[str, Any]], None]] = None,
) -> Dict[str, Any]:
    """Run the agent loop until it stops calling tools (or hits max_iterations).

    Returns {"steps": [...], "final_text": str, "iterations": int,
             "stopped_early": bool, "usage": {...}}
    """
    task = AGENT_TASK_TEMPLATE.format(
        prompt_block=json.dumps(prompt_block, indent=2, ensure_ascii=False)
    )

    def emit(ev: Dict[str, Any]) -> None:
        if on_event:
            try:
                on_event(ev)
            except Exception:
                pass

    provider = provider_for(model)
    if provider == "anthropic":
        return _run_anthropic(
            model, workspace, task, api_key, effort, max_iterations, emit
        )
    if provider == "openai":
        return _run_openai(model, workspace, task, api_key, effort, max_iterations, emit)
    raise ValueError(
        f"Unknown model '{model}' — use a claude-* (Anthropic) or gpt-*/o*-* (OpenAI) id."
    )


# ---------------------------------------------------------------------------
# Anthropic
# ---------------------------------------------------------------------------

def _run_anthropic(
    model: str,
    ws: Workspace,
    task: str,
    api_key: Optional[str],
    effort: Optional[str],
    max_iterations: int,
    emit: Callable[[Dict[str, Any]], None],
) -> Dict[str, Any]:
    from anthropic import Anthropic

    client = Anthropic(api_key=api_key) if api_key else Anthropic()
    tools = anthropic_tools()
    messages: List[Dict[str, Any]] = [{"role": "user", "content": task}]
    steps: List[Dict[str, Any]] = []
    usage = {"input_tokens": 0, "output_tokens": 0}
    final_text = ""
    stopped_early = True
    i = 0

    for i in range(1, max_iterations + 1):
        kwargs: Dict[str, Any] = {}
        if effort:
            kwargs["output_config"] = {"effort": effort}
        resp = client.messages.create(
            model=model,
            max_tokens=_max_tokens(effort),
            system=[{"type": "text", "text": SYSTEM_PROMPT}],
            tools=tools,
            messages=messages,
            **kwargs,
        )
        if getattr(resp, "usage", None):
            usage["input_tokens"] += getattr(resp.usage, "input_tokens", 0) or 0
            usage["output_tokens"] += getattr(resp.usage, "output_tokens", 0) or 0

        text = "".join(
            b.text for b in resp.content if getattr(b, "type", "") == "text"
        ).strip()
        if text:
            steps.append({"kind": "text", "iteration": i, "text": text})
            emit({"kind": "text", "iteration": i, "text": text})

        # Keep the assistant turn verbatim (preserves any thinking blocks).
        messages.append({"role": "assistant", "content": resp.content})

        tool_uses = [b for b in resp.content if getattr(b, "type", "") == "tool_use"]
        if not tool_uses:
            final_text = text
            stopped_early = False
            break

        results = []
        for tu in tool_uses:
            args = dict(tu.input or {})
            out = ws.call(tu.name, args)
            steps.append(
                {"kind": "tool", "iteration": i, "name": tu.name, "args": args, "result": out}
            )
            emit({"kind": "tool", "iteration": i, "name": tu.name, "args": args, "result": out})
            results.append(
                {"type": "tool_result", "tool_use_id": tu.id, "content": out}
            )
        messages.append({"role": "user", "content": results})

    return {
        "steps": steps,
        "final_text": final_text,
        "iterations": i,
        "stopped_early": stopped_early,
        "usage": usage,
    }


# ---------------------------------------------------------------------------
# OpenAI
# ---------------------------------------------------------------------------

def _run_openai(
    model: str,
    ws: Workspace,
    task: str,
    api_key: Optional[str],
    effort: Optional[str],
    max_iterations: int,
    emit: Callable[[Dict[str, Any]], None],
) -> Dict[str, Any]:
    from openai import OpenAI

    client = OpenAI(api_key=api_key) if api_key else OpenAI()
    tools = openai_tools()
    messages: List[Any] = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": task},
    ]
    steps: List[Dict[str, Any]] = []
    usage = {"input_tokens": 0, "output_tokens": 0}
    final_text = ""
    stopped_early = True
    i = 0

    for i in range(1, max_iterations + 1):
        kwargs: Dict[str, Any] = {}
        if effort:
            kwargs["reasoning_effort"] = effort
        resp = client.chat.completions.create(
            model=model, messages=messages, tools=tools, **kwargs
        )
        if getattr(resp, "usage", None):
            usage["input_tokens"] += getattr(resp.usage, "prompt_tokens", 0) or 0
            usage["output_tokens"] += getattr(resp.usage, "completion_tokens", 0) or 0

        msg = resp.choices[0].message
        text = (msg.content or "").strip()
        if text:
            steps.append({"kind": "text", "iteration": i, "text": text})
            emit({"kind": "text", "iteration": i, "text": text})

        messages.append(msg)

        calls = list(msg.tool_calls or [])
        if not calls:
            final_text = text
            stopped_early = False
            break

        for tc in calls:
            try:
                args = json.loads(tc.function.arguments or "{}")
            except Exception:
                args = {}
            out = ws.call(tc.function.name, args)
            steps.append(
                {
                    "kind": "tool",
                    "iteration": i,
                    "name": tc.function.name,
                    "args": args,
                    "result": out,
                }
            )
            emit(
                {
                    "kind": "tool",
                    "iteration": i,
                    "name": tc.function.name,
                    "args": args,
                    "result": out,
                }
            )
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": out})

    return {
        "steps": steps,
        "final_text": final_text,
        "iterations": i,
        "stopped_early": stopped_early,
        "usage": usage,
    }