TRL: inject text/JSON tool calls + forced ls into tool loop
Browse files- kaggle/train_grpo.py +87 -1
kaggle/train_grpo.py
CHANGED
|
@@ -282,6 +282,90 @@ def _parse_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
| 282 |
return calls
|
| 283 |
|
| 284 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
def run_heldout_agent_eval(
|
| 286 |
model,
|
| 287 |
tokenizer,
|
|
@@ -1385,14 +1469,16 @@ def main(cfg: Cfg | None = None) -> None:
|
|
| 1385 |
max_eval_turns=cfg.max_eval_turns,
|
| 1386 |
)
|
| 1387 |
|
|
|
|
| 1388 |
_vlog(
|
| 1389 |
"main.trainer_init",
|
| 1390 |
environment="DeepAgentSWEEnv",
|
|
|
|
| 1391 |
max_steps=cfg.max_steps,
|
| 1392 |
num_generations=cfg.num_generations,
|
| 1393 |
max_tool_calling_iterations=cfg.max_tool_calling_iterations,
|
| 1394 |
)
|
| 1395 |
-
trainer =
|
| 1396 |
model=model,
|
| 1397 |
reward_funcs=[log_completions_reward], # logs only; env get_reward still scores SWE
|
| 1398 |
args=args,
|
|
|
|
| 282 |
return calls
|
| 283 |
|
| 284 |
|
| 285 |
+
def _parsed_to_openai_tool_calls(parsed: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 286 |
+
out = []
|
| 287 |
+
for i, p in enumerate(parsed):
|
| 288 |
+
args = p.get("arguments") if isinstance(p.get("arguments"), dict) else {}
|
| 289 |
+
out.append(
|
| 290 |
+
{
|
| 291 |
+
"id": f"call_text_{i}",
|
| 292 |
+
"type": "function",
|
| 293 |
+
"function": {
|
| 294 |
+
"name": str(p["name"]),
|
| 295 |
+
"arguments": args,
|
| 296 |
+
},
|
| 297 |
+
}
|
| 298 |
+
)
|
| 299 |
+
return out
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def _inject_text_tool_calls_into_completions(
|
| 303 |
+
completions: list[Any],
|
| 304 |
+
*,
|
| 305 |
+
force_ls_if_empty: bool = True,
|
| 306 |
+
) -> tuple[list[Any], int]:
|
| 307 |
+
"""TRL only runs tools from native ``tool_calls``. Qwen often emits markdown JSON — promote it."""
|
| 308 |
+
injected = 0
|
| 309 |
+
fixed: list[Any] = []
|
| 310 |
+
for comp in completions:
|
| 311 |
+
# conversational: [[{role, content, tool_calls?}]]
|
| 312 |
+
if (
|
| 313 |
+
isinstance(comp, list)
|
| 314 |
+
and comp
|
| 315 |
+
and isinstance(comp[0], dict)
|
| 316 |
+
and comp[0].get("role") == "assistant"
|
| 317 |
+
):
|
| 318 |
+
msg = dict(comp[0])
|
| 319 |
+
existing = msg.get("tool_calls") or []
|
| 320 |
+
if existing:
|
| 321 |
+
fixed.append([msg] + list(comp[1:]))
|
| 322 |
+
continue
|
| 323 |
+
content = str(msg.get("content") or "")
|
| 324 |
+
parsed = _parse_tool_calls(content)
|
| 325 |
+
if not parsed and force_ls_if_empty:
|
| 326 |
+
# Last resort so the GRPO group is not all "DONE"/no-tools.
|
| 327 |
+
parsed = [{"name": "ls", "arguments": {"path": "."}}]
|
| 328 |
+
msg["content"] = (content + "\n").strip() + "\n(forced tool: ls path='.')"
|
| 329 |
+
if parsed:
|
| 330 |
+
msg["tool_calls"] = _parsed_to_openai_tool_calls(parsed)
|
| 331 |
+
injected += 1
|
| 332 |
+
_vlog(
|
| 333 |
+
"train.inject_text_tool_calls",
|
| 334 |
+
n=len(parsed),
|
| 335 |
+
names=[p["name"] for p in parsed],
|
| 336 |
+
forced=not bool(_parse_tool_calls(content)),
|
| 337 |
+
)
|
| 338 |
+
fixed.append([msg] + list(comp[1:]))
|
| 339 |
+
else:
|
| 340 |
+
fixed.append(comp)
|
| 341 |
+
return fixed, injected
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def make_tool_repair_grpo_trainer(base_cls: type):
|
| 345 |
+
"""Subclass GRPOTrainer to execute markdown/JSON tool blobs + forced ls."""
|
| 346 |
+
|
| 347 |
+
class ToolRepairGRPOTrainer(base_cls): # type: ignore[misc,valid-type]
|
| 348 |
+
def _tool_call_loop(self, prompts, prompt_ids, completion_ids, completions, logprobs, images, multimodal_fields):
|
| 349 |
+
force = os.getenv("DARL_FORCE_FIRST_TOOL", "1").strip() not in ("0", "false", "no")
|
| 350 |
+
completions, n_inj = _inject_text_tool_calls_into_completions(
|
| 351 |
+
completions, force_ls_if_empty=force
|
| 352 |
+
)
|
| 353 |
+
if n_inj:
|
| 354 |
+
_vlog("train.tool_repair.injected", n=n_inj, force=force)
|
| 355 |
+
return super()._tool_call_loop(
|
| 356 |
+
prompts,
|
| 357 |
+
prompt_ids,
|
| 358 |
+
completion_ids,
|
| 359 |
+
completions,
|
| 360 |
+
logprobs,
|
| 361 |
+
images,
|
| 362 |
+
multimodal_fields,
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
ToolRepairGRPOTrainer.__name__ = "ToolRepairGRPOTrainer"
|
| 366 |
+
return ToolRepairGRPOTrainer
|
| 367 |
+
|
| 368 |
+
|
| 369 |
def run_heldout_agent_eval(
|
| 370 |
model,
|
| 371 |
tokenizer,
|
|
|
|
| 1469 |
max_eval_turns=cfg.max_eval_turns,
|
| 1470 |
)
|
| 1471 |
|
| 1472 |
+
TrainerCls = make_tool_repair_grpo_trainer(GRPOTrainer)
|
| 1473 |
_vlog(
|
| 1474 |
"main.trainer_init",
|
| 1475 |
environment="DeepAgentSWEEnv",
|
| 1476 |
+
trainer=TrainerCls.__name__,
|
| 1477 |
max_steps=cfg.max_steps,
|
| 1478 |
num_generations=cfg.num_generations,
|
| 1479 |
max_tool_calling_iterations=cfg.max_tool_calling_iterations,
|
| 1480 |
)
|
| 1481 |
+
trainer = TrainerCls(
|
| 1482 |
model=model,
|
| 1483 |
reward_funcs=[log_completions_reward], # logs only; env get_reward still scores SWE
|
| 1484 |
args=args,
|