tdb-intake / lib /agent_loop.py
tttjjj's picture
Turn Run agent into a real agentic loop with a workspace and tools
74239a8
Raw History Blame Contribute Delete
8.55 kB
"""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,
}