| """Shared agent core: chat template, instructions, tool-call parsing, episode logic. |
| |
| This exact module drives (1) RL training rollouts, (2) every evaluation, and |
| (3) the shipped standalone tool — zero train/inference mismatch. |
| |
| Conventions (inherited from the reference client): |
| - NO system role: instructions are merged into the first user message. |
| - Strict user/assistant alternation; tool output comes back as the next user |
| message, prefixed "Tool output: ". |
| - Plain-text tool calls, types "bash" and "final", lenient regex parsing, and |
| parse errors are fed back to the model as the next user message. |
| |
| Format: reasoning goes in <think>...</think>, the tool call in <answer>...</answer> |
| (the reference's "Thoughts:" field is dropped — it is redundant with <think>). |
| Assistant turns end with "</answer>" followed by EOS. Past assistant turns are |
| rendered with the <think> block stripped (saves context; template handles it). |
| """ |
| import re |
|
|
| |
| |
| |
|
|
| PREAMBLE = ( |
| "A conversation between User and Assistant. The user asks a question, and the " |
| "Assistant solves it. The assistant first thinks about the reasoning process in " |
| "the mind and then provides the user with the answer. The reasoning process and " |
| "answer are enclosed within <think>...</think> and <answer>...</answer> tags, " |
| "respectively, i.e., <think> reasoning process here </think> " |
| "<answer>answer here </answer>." |
| ) |
|
|
| |
| |
| CHAT_TEMPLATE = ( |
| "{{- '" + PREAMBLE.replace("'", "\\'") + "\\n\\n' -}}" |
| "{%- for message in messages -%}" |
| "{%- if message['role'] == 'user' -%}" |
| "{{- 'User: ' + message['content'] + '\\n\\n' -}}" |
| "{%- elif message['role'] == 'assistant' -%}" |
| "{%- set content = message['content'] -%}" |
| "{%- if '</think>' in content -%}" |
| "{%- set content = content.split('</think>')[-1] -%}" |
| "{%- endif -%}" |
| "{{- 'Assistant: ' + content.strip() + '\\n\\n' -}}" |
| "{%- endif -%}" |
| "{%- endfor -%}" |
| "{%- if add_generation_prompt -%}" |
| "{{- 'Assistant: <think>\\n' -}}" |
| "{%- endif -%}" |
| ) |
|
|
| THINK_PREFIX = "<think>\n" |
| STOP_STRING = "</answer>" |
|
|
| |
| GEN_TEMPERATURE = 1.0 |
| GEN_TOP_P = 0.95 |
| MAX_TURN_TOKENS = 1024 |
|
|
|
|
| def render_messages(messages, add_generation_prompt: bool = True) -> str: |
| """Render messages exactly like CHAT_TEMPLATE, without depending on Jinja. |
| |
| Offline vLLM/Transformers calls use this function; server-mode vLLM uses the |
| tokenizer chat template below. Keep both byte-identical. |
| """ |
| parts = [PREAMBLE + "\n\n"] |
| for message in messages: |
| role = message["role"] |
| content = message["content"] |
| if role == "user": |
| parts.append("User: " + content + "\n\n") |
| elif role == "assistant": |
| if "</think>" in content: |
| content = content.split("</think>")[-1] |
| parts.append("Assistant: " + content.strip() + "\n\n") |
| if add_generation_prompt: |
| parts.append("Assistant: <think>\n") |
| return "".join(parts) |
|
|
| |
| |
| |
|
|
| TOOL_INSTRUCTIONS = """You are an AI agentic coding assistant. You complete the user's task by calling tools step by step, one tool call per turn. |
| |
| First reason inside <think>...</think>, then put exactly one tool call inside <answer>...</answer> in exactly this format: |
| |
| <answer>Tool type: [bash or final] |
| Tool query: [the shell command to run, or the final answer]</answer> |
| |
| Available tools: |
| 1. bash: runs a shell command in an isolated Linux sandbox and returns its output (e.g. write files with heredocs, run python, test code). The sandbox is stateless: the working directory and variables reset between calls, so chain commands with && where needed. |
| 2. final: ends the task; put the complete final answer in Tool query. |
| |
| The next user message will contain the tool output as "Tool output: ...". Keep outputs small (use head/tail). After your final tool call the episode ends.""" |
|
|
|
|
| def coding_task_message(statement: str, starter_code: str = "", max_turns: int = 2) -> str: |
| """First user message for a competitive-programming task (training AND eval).""" |
| if starter_code and starter_code.strip(): |
| req = ( |
| "Write a Python 3 solution that completes the following starter code, " |
| "keeping the exact same class name and method signature:\n" |
| "```python\n" + starter_code.strip() + "\n```" |
| ) |
| else: |
| req = ("Write a complete Python 3 program that reads the input from stdin " |
| "and prints the required output to stdout.") |
| n_bash = max_turns - 1 |
| one_turn_rule = ( |
| "- This is a one-turn episode: NEVER call `bash`; submit the complete Python solution " |
| "with your single `final` tool call immediately.\n" |
| if max_turns == 1 else "" |
| ) |
| return f"""{TOOL_INSTRUCTIONS} |
| |
| Task: solve this competitive programming problem in Python 3. |
| |
| {statement.strip()} |
| |
| {req} |
| |
| Rules: |
| - You have at most {max_turns} tool calls in total, so at most {n_bash} bash call{"s" if n_bash != 1 else ""}; you may use bash to test your solution on the example input before submitting. |
| {one_turn_rule}- Your last tool call must be "final" and its Tool query must contain ONLY your complete solution inside one ```python ... ``` code block. |
| - The solution is judged against hidden tests; handle all edge cases and stay within a few seconds of runtime.""" |
|
|
|
|
| PARSE_ERROR_FEEDBACK = """Parsing Error: {err} |
| Please repeat, strictly matching this format: |
| |
| <answer>Tool type: [bash or final] |
| Tool query: [the shell command, or the final answer]</answer>""" |
|
|
| |
| |
| |
|
|
|
|
| def parse_action(text: str): |
| """Parse an assistant completion into (tool_type, tool_query, error). |
| |
| `text` is the completion text (reasoning + <answer> block, with or without |
| the closing tag). Lenient: falls back to scanning the whole text. |
| """ |
| m = re.search(r"<answer>(.*?)</answer>", text, re.DOTALL) |
| if m: |
| seg = m.group(1) |
| elif "<answer>" in text: |
| seg = text.split("<answer>", 1)[1] |
| else: |
| seg = text.split("</think>")[-1] |
|
|
| tt = re.search(r"Tool type:\s*(.*?)(?=\n\s*Tool query:|$)", seg, re.DOTALL | re.IGNORECASE) |
| tq = re.search(r"Tool query:\s*(.*)", seg, re.DOTALL | re.IGNORECASE) |
| tool_type = tt.group(1).strip().lower() if tt else None |
| |
| |
| |
| if tool_type: |
| tool_type = tool_type.strip(" \t[](){}<>`*_\"'") |
| tool_query = tq.group(1).strip() if tq else None |
|
|
| if not tool_type or tool_query is None or tool_query == "": |
| return None, None, ("Could not parse 'Tool type' or 'Tool query' from the response. " |
| "Ensure you output an <answer> block with 'Tool type:' and 'Tool query:'.") |
| if tool_type not in ("bash", "final"): |
| return None, None, f"Unknown tool type '{tool_type}'; must be 'bash' or 'final'." |
| if tool_type == "bash": |
| |
| tool_query = re.sub(r"^```[a-zA-Z]*\n", "", tool_query) |
| tool_query = re.sub(r"\n```$", "", tool_query) |
| tool_query = tool_query.strip() |
| return tool_type, tool_query, None |
|
|
|
|
| def extract_code(final_answer: str) -> str: |
| """Extract the python solution from a final answer (last ```python block, else raw).""" |
| blocks = re.findall(r"```(?:python|py)?\n(.*?)```", final_answer, re.DOTALL) |
| if blocks: |
| return blocks[-1].strip() |
| return final_answer.strip() |
|
|
|
|
| def normalize_completion(content: str, finish_reason: str = "stop") -> str: |
| """Make server-mode completions byte-identical to training rollouts: the stop |
| string is part of the turn. If the server stripped it (or stopped at EOS right |
| after it), re-append.""" |
| if STOP_STRING not in content and finish_reason == "stop" and "<answer>" in content: |
| content = content.rstrip() + STOP_STRING |
| return content |
|
|
|
|
| |
| |
| |
|
|
|
|
| class Episode: |
| """One agentic episode with strict user/assistant alternation. |
| |
| Use: messages -> (render+generate outside) -> step(completion) which returns |
| ("bash", cmd) | ("final", answer) | ("continue", None) after appending messages. |
| After executing a bash command, call add_tool_output(out). |
| """ |
|
|
| def __init__(self, first_user_msg: str, max_turns: int = 2, meta=None): |
| self.messages = [{"role": "user", "content": first_user_msg}] |
| self.max_turns = max_turns |
| self.turns = 0 |
| self.done = False |
| self.final_answer = None |
| self.end_reason = None |
| self.meta = meta or {} |
|
|
| def step(self, completion: str): |
| """completion: model output text (starts after 'Assistant: <think>\\n').""" |
| assert not self.done |
| content = THINK_PREFIX + completion |
| self.messages.append({"role": "assistant", "content": content}) |
| self.turns += 1 |
| tool_type, tool_query, err = parse_action(completion) |
|
|
| if err is not None: |
| if self.turns >= self.max_turns: |
| self.done, self.end_reason = True, "parse_error" |
| return ("continue", None) |
| self.add_tool_output(PARSE_ERROR_FEEDBACK.format(err=err)) |
| return ("continue", None) |
|
|
| if tool_type == "final": |
| self.done, self.end_reason = True, "final" |
| self.final_answer = tool_query |
| return ("final", tool_query) |
|
|
| |
| if self.turns >= self.max_turns: |
| self.done, self.end_reason = True, "out_of_turns" |
| return ("continue", None) |
| return ("bash", tool_query) |
|
|
| def add_tool_output(self, output: str): |
| self.messages.append({"role": "user", "content": f"Tool output: {output}"}) |
|
|
|
|
| def run_episode(generate_fn, bash_fn, first_user_msg: str, max_turns: int = 2, meta=None): |
| """Sequential episode driver (eval + shipped tool). generate_fn(messages)->completion.""" |
| ep = Episode(first_user_msg, max_turns=max_turns, meta=meta) |
| while not ep.done: |
| completion = generate_fn(ep.messages) |
| kind, arg = ep.step(completion) |
| if kind == "bash": |
| ep.add_tool_output(bash_fn(arg)) |
| return ep |
|
|
|
|
| def openai_generate_fn(client, model: str, temperature: float = GEN_TEMPERATURE, |
| top_p: float = GEN_TOP_P, max_tokens: int = MAX_TURN_TOKENS): |
| """generate_fn over an OpenAI-compatible chat endpoint (vLLM server). The server |
| applies this repo's chat template (shipped in the tokenizer).""" |
| def fn(messages): |
| kwargs = dict(model=model, messages=messages, temperature=temperature, |
| top_p=top_p, max_tokens=max_tokens, stop=[STOP_STRING]) |
| try: |
| resp = client.chat.completions.create( |
| **kwargs, extra_body={"include_stop_str_in_output": True}) |
| except Exception: |
| resp = client.chat.completions.create(**kwargs) |
| choice = resp.choices[0] |
| return normalize_completion(choice.message.content or "", choice.finish_reason) |
| return fn |
|
|