From a195db4ff7004e62b47345b526cf6e8010db547a Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Sun, 16 Aug 2026 02:05:05 +0000 Subject: [PATCH 1/7] feat(examples): add coding agent harness state machine and tools --- examples/coding-agent/__init__.py | 16 ++ examples/coding-agent/application.py | 271 +++++++++++++++++++++++++ examples/coding-agent/statemachine.png | Bin 0 -> 34879 bytes examples/coding-agent/tools.py | 89 ++++++++ 4 files changed, 376 insertions(+) create mode 100644 examples/coding-agent/__init__.py create mode 100644 examples/coding-agent/application.py create mode 100644 examples/coding-agent/statemachine.png create mode 100644 examples/coding-agent/tools.py diff --git a/examples/coding-agent/__init__.py b/examples/coding-agent/__init__.py new file mode 100644 index 000000000..13a83393a --- /dev/null +++ b/examples/coding-agent/__init__.py @@ -0,0 +1,16 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. diff --git a/examples/coding-agent/application.py b/examples/coding-agent/application.py new file mode 100644 index 000000000..5a66c0ca9 --- /dev/null +++ b/examples/coding-agent/application.py @@ -0,0 +1,271 @@ +"""Tools the coding agent can call. Each takes typed args and returns a dict.""" +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import inspect +import json +import os +from typing import Callable, Optional + +from burr.core import State, action, expr, when +from burr.core.application import ApplicationBuilder + +from tools import list_files, read_file, run_bash, write_file + +TOOLS = { + "list_files": list_files, + "read_file": read_file, + "write_file": write_file, + "run_bash": run_bash, +} + +TYPE_MAP = {str: "string", int: "integer", float: "number", bool: "boolean"} + +OPENAI_TOOLS = [ + { + "type": "function", + "function": { + "name": name, + "description": fn.__doc__ or name, + "parameters": { + "type": "object", + "properties": { + p.name: { + "type": TYPE_MAP.get(p.annotation, "string"), + "description": p.name, + } + for p in inspect.signature(fn).parameters.values() + }, + "required": [ + p.name + for p in inspect.signature(fn).parameters.values() + if p.default is inspect.Parameter.empty + ], + }, + }, + } + for name, fn in TOOLS.items() +] + +SYSTEM_PROMPT = ( + "You are a coding agent working inside a project directory. " + "Use the tools to inspect and modify files, and to run commands. " + "Work one step at a time: look before you edit, and verify changes by running them. " + "When the task is complete, reply with a short summary and request no tool." +) + + +# --- LLM clients ----------------------------------------------------------- +# Both return the same shape: +# {"content": str | None, "tool_calls": [{"id", "name", "args"}]} + + +class OpenAIClient: + """Calls OpenAI's chat completions API with tool calling enabled.""" + + def __init__(self, model: str = "gpt-4o"): + self.model = model + + def __call__(self, messages: list[dict]) -> dict: + import openai + + response = openai.chat.completions.create( + model=self.model, messages=messages, tools=OPENAI_TOOLS + ) + message = response.choices[0].message + calls = [ + {"id": c.id, "name": c.function.name, "args": json.loads(c.function.arguments)} + for c in (message.tool_calls or []) + ] + return {"content": message.content, "tool_calls": calls} + + +class ScriptedClient: + """Replays a fixed list of responses. Lets the example run without an API key.""" + + def __init__(self, responses: list[dict]): + self.responses = list(responses) + self.index = 0 + + def __call__(self, messages: list[dict]) -> dict: + if self.index >= len(self.responses): + return {"content": "Script exhausted.", "tool_calls": []} + response = self.responses[self.index] + self.index += 1 + return response + + +DEFAULT_SCRIPT = [ + {"content": None, "tool_calls": [{"id": "c1", "name": "list_files", "args": {}}]}, + { + "content": None, + "tool_calls": [ + {"id": "c2", "name": "write_file", + "args": {"path": "hello.py", "contents": "print('hello from the agent')\n"}} + ], + }, + {"content": None, "tool_calls": [{"id": "c3", "name": "run_bash", + "args": {"command": "python hello.py"}}]}, + {"content": "Created hello.py and confirmed it runs.", "tool_calls": []}, +] + + +def get_client(): + """Real client when OPENAI_API_KEY is set, otherwise the scripted stand-in.""" + if os.environ.get("OPENAI_API_KEY"): + return OpenAIClient() + return ScriptedClient(DEFAULT_SCRIPT) + + +# --- Actions --------------------------------------------------------------- + + +@action(reads=[], writes=["task", "messages", "steps", "done", "final_answer"]) +def human_input(state: State, task: str) -> State: + """Takes a task from the user and starts a fresh run.""" + return state.update( + task=task, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": task}, + ], + steps=0, + done=False, + final_answer=None, + ) + + +@action(reads=["messages"], writes=["messages"]) +def create_prompt(state: State) -> State: + """Seam for shaping the prompt before each call -- add file trees, summaries, etc.""" + return state.update(messages=state["messages"]) + + +@action( + reads=["messages", "steps"], + writes=["messages", "next_tool", "next_args", "last_tool_call_id", + "steps", "done", "final_answer"], +) +def call_llm(state: State, client: Callable) -> State: + """Asks the model what to do next: call a tool, or finish.""" + result = client(state["messages"]) + calls = result["tool_calls"] + + if not calls: + return state.update( + messages=state["messages"] + [{"role": "assistant", "content": result["content"]}], + next_tool=None, + next_args={}, + last_tool_call_id=None, + steps=state["steps"] + 1, + done=True, + final_answer=result["content"], + ) + + # One tool per iteration keeps the graph readable; extras are dropped. + call = calls[0] + assistant_message = { + "role": "assistant", + "content": result["content"], + "tool_calls": [ + { + "id": call["id"], + "type": "function", + "function": {"name": call["name"], "arguments": json.dumps(call["args"])}, + } + ], + } + return state.update( + messages=state["messages"] + [assistant_message], + next_tool=call["name"], + next_args=call["args"], + last_tool_call_id=call["id"], + steps=state["steps"] + 1, + done=False, + final_answer=None, + ) + + +@action(reads=["next_args", "last_tool_call_id", "messages"], writes=["messages"]) +def execute_tool(state: State, tool_function: Callable) -> State: + """Runs one tool and feeds its result back to the model.""" + result = tool_function(**state["next_args"]) + return state.update( + messages=state["messages"] + + [ + { + "role": "tool", + "tool_call_id": state["last_tool_call_id"], + "content": json.dumps(result), + } + ] + ) + + +@action(reads=["final_answer", "steps", "max_steps"], writes=["final_answer"]) +def respond(state: State) -> State: + """Surfaces the answer, or explains that the budget ran out.""" + if state["final_answer"]: + return state.update(final_answer=state["final_answer"]) + return state.update( + final_answer=f"Stopped after {state['steps']} steps without finishing the task." + ) + + +def application(app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None): + """Builds the coding agent application.""" + client = client or get_client() + return ( + ApplicationBuilder() + .with_actions( + human_input, + create_prompt, + respond, + call_llm=call_llm.bind(client=client), + read_file=execute_tool.bind(tool_function=read_file), + write_file=execute_tool.bind(tool_function=write_file), + list_files=execute_tool.bind(tool_function=list_files), + run_bash=execute_tool.bind(tool_function=run_bash), + ) + .with_transitions( + ("human_input", "create_prompt"), + ("create_prompt", "call_llm"), + ("call_llm", "respond", when(done=True)), + ("call_llm", "respond", expr("steps>=max_steps")), + ("call_llm", "read_file", when(next_tool="read_file")), + ("call_llm", "write_file", when(next_tool="write_file")), + ("call_llm", "list_files", when(next_tool="list_files")), + ("call_llm", "run_bash", when(next_tool="run_bash")), + (["read_file", "write_file", "list_files", "run_bash"], "call_llm"), + ("respond", "human_input"), + ) + .with_state(max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None) + .with_identifiers(app_id=app_id) + .with_entrypoint("human_input") + .with_tracker(project="demo_coding_agent") + .build() + ) + + +if __name__ == "__main__": + app = application() + app.visualize(output_file_path="./statemachine.png") + _, _, state = app.run( + halt_after=["respond"], + inputs={"task": "Create a hello.py that prints a greeting, then run it."}, + ) + print(state["final_answer"]) \ No newline at end of file diff --git a/examples/coding-agent/statemachine.png b/examples/coding-agent/statemachine.png new file mode 100644 index 0000000000000000000000000000000000000000..701ea129cd32d945fe012d5e05b05733e1a1003e GIT binary patch literal 34879 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 str: + """Resolve a path inside the workspace, refusing anything that escapes it.""" + root = os.path.abspath(WORKSPACE) + full = os.path.abspath(os.path.join(root, path)) + if not full.startswith(root + os.sep) and full != root: + raise ValueError(f"path escapes workspace: {path}") + return full + + +def list_files(directory: str = ".") -> dict: + """Lists the files and directories at the given path inside the workspace.""" + try: + target = _resolve(directory) + return {"entries": sorted(os.listdir(target))} + except (ValueError, OSError) as e: + return {"error": str(e)} + + +def read_file(path: str) -> dict: + """Reads a text file from the workspace and returns its contents.""" + try: + with open(_resolve(path)) as f: + return {"path": path, "contents": f.read()} + except (ValueError, OSError) as e: + return {"error": str(e)} + + +def write_file(path: str, contents: str) -> dict: + """Writes text to a file in the workspace, creating or overwriting it.""" + try: + full = _resolve(path) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w") as f: + f.write(contents) + return {"path": path, "bytes_written": len(contents)} + except (ValueError, OSError) as e: + return {"error": str(e)} + + +def run_bash(command: str) -> dict: + """Runs a shell command in the workspace and returns its output.""" + try: + proc = subprocess.run( + command, shell=True, cwd=os.path.abspath(WORKSPACE), + capture_output=True, text=True, timeout=30, + ) + return { + "exit_code": proc.returncode, + "stdout": proc.stdout[-4000:], + "stderr": proc.stderr[-4000:], + } + except subprocess.TimeoutExpired: + return {"error": "command timed out after 30s"} \ No newline at end of file From d84a0717479d99e3c2b3afd1a90a83c9193d6925 Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Sun, 16 Aug 2026 02:37:23 +0000 Subject: [PATCH 2/7] docs(examples): add README and notebook for coding agent example --- examples/coding-agent/README.md | 74 +++ examples/coding-agent/notebook.ipynb | 716 +++++++++++++++++++++++++ examples/coding-agent/requirements.txt | 19 + 3 files changed, 809 insertions(+) create mode 100644 examples/coding-agent/README.md create mode 100644 examples/coding-agent/notebook.ipynb create mode 100644 examples/coding-agent/requirements.txt diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md new file mode 100644 index 000000000..d37c5c097 --- /dev/null +++ b/examples/coding-agent/README.md @@ -0,0 +1,74 @@ +# Coding agent + +A minimal coding agent harness: an LLM that reads files, writes files, and runs +shell commands in a loop until it has finished a task. + +The point of this example is the *loop*. A tool-calling assistant runs one tool +and returns to the user. An agent keeps going on its own -- it reads a file, +sees what's in it, edits it, runs the tests, sees them fail, and tries again -- +without a human in between. That is a cycle, and expressing cycles clearly is +what Burr is for. + +![State machine](statemachine.png) + +## The loop + +| Action | What it does | +| --- | --- | +| `human_input` | Takes the task and seeds the message history | +| `create_prompt` | A seam for shaping the prompt before each call -- add a file tree, a summary of earlier steps, retrieved context | +| `call_llm` | Asks the model what to do next: call a tool, or finish | +| `read_file` / `write_file` / `list_files` / `run_bash` | One bound action per tool, so each shows up separately in the Burr UI | +| `respond` | Surfaces the answer, or reports that the step budget ran out | + +Two transitions leave `call_llm` for `respond`: one when the model answers +without requesting a tool, and one when `steps` reaches `max_steps`. Both exits +are listed before the tool transitions, because Burr takes the first condition +that matches. An agent that decides its own next step can loop forever, so the +budget is not optional. + +Tool results are appended to `messages` as `role: "tool"` entries carrying the +`tool_call_id` they answer. That is what lets the model see what its last action +actually returned. + +## Running it + + pip install -r requirements.txt + python application.py + +With no API key set, the example uses a scripted client that replays a fixed +sequence of tool calls, so it runs immediately and in CI. Set `OPENAI_API_KEY` +to use a real model instead. + +Regenerating `statemachine.png` needs the Graphviz binary (`apt-get install +graphviz`), not just the Python package. + +To watch a run in the Burr UI: + + burr + +## Safety + +`run_bash` executes shell commands with the permissions of the process running +the agent. File paths are confined to the workspace directory, but a shell +command can simply `cd` out of it. **This is a teaching example, not a sandbox.** +Run it against a directory you do not mind losing, ideally inside a container. + +Adding a permission step before tool execution -- prompting for approval, or +checking an allowlist -- is the natural next thing to build. + +## Known simplifications + +- One tool call is executed per iteration; if the model requests several, the + extras are dropped. +- `create_prompt` is a passthrough. It exists to show where prompt construction + belongs, not because it does anything yet. +- Message history grows without bound. A longer-running agent needs summarisation + or truncation. + +## Files + +- [application.py](application.py) -- the state machine, actions, and LLM clients +- [tools.py](tools.py) -- the four tools +- [notebook.ipynb](notebook.ipynb) -- the same example, walked through step by step +- [requirements.txt](requirements.txt) -- the environment \ No newline at end of file diff --git a/examples/coding-agent/notebook.ipynb b/examples/coding-agent/notebook.ipynb new file mode 100644 index 000000000..ce354a34f --- /dev/null +++ b/examples/coding-agent/notebook.ipynb @@ -0,0 +1,716 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# Licensed to the Apache Software Foundation (ASF) under one\n", + "# or more contributor license agreements. See the NOTICE file\n", + "# distributed with this work for additional information\n", + "# regarding copyright ownership. The ASF licenses this file\n", + "# to you under the Apache License, Version 2.0 (the\n", + "# \"License\"); you may not use this file except in compliance\n", + "# with the License. You may obtain a copy of the License at\n", + "#\n", + "# http://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing,\n", + "# software distributed under the License is distributed on an\n", + "# \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n", + "# KIND, either express or implied. See the License for the\n", + "# specific language governing permissions and limitations\n", + "# under the License." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Coding agent\n", + "\n", + "A minimal coding agent harness: an LLM that reads files, writes files, and runs\n", + "shell commands in a loop until the task is done.\n", + "\n", + "The point is the **loop**. A tool-calling assistant runs one tool and hands back\n", + "to the user. An agent keeps going by itself -- read a file, edit it, run the\n", + "tests, see them fail, try again -- with no human in between. That is a cycle,\n", + "and cycles are what Burr is for.\n", + "\n", + "This notebook runs without an API key: a scripted client stands in for the model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Imports" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "import inspect\n", + "import json\n", + "import os\n", + "import subprocess\n", + "from typing import Callable, Optional\n", + "\n", + "from burr.core import State, action, expr, when\n", + "from burr.core.application import ApplicationBuilder" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# The tools\n", + "\n", + "Four functions. Each takes primitive, type-annotated arguments and returns a\n", + "dict -- and never raises. An exception would kill the state machine; an\n", + "`{\"error\": ...}` dict goes back to the model, which can then try something else.\n", + "\n", + "Paths are confined to a workspace directory. Note that `run_bash` can `cd` out\n", + "of it: this is a teaching example, not a sandbox." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Tools the coding agent can call. Each takes typed args and returns a dict.\"\"\"\n", + "# Licensed to the Apache Software Foundation (ASF) under one\n", + "# or more contributor license agreements. See the NOTICE file\n", + "# distributed with this work for additional information\n", + "# regarding copyright ownership. The ASF licenses this file\n", + "# to you under the Apache License, Version 2.0 (the\n", + "# \"License\"); you may not use this file except in compliance\n", + "# with the License. You may obtain a copy of the License at\n", + "#\n", + "# http://www.apache.org/licenses/LICENSE-2.0\n", + "#\n", + "# Unless required by applicable law or agreed to in writing,\n", + "# software distributed under the License is distributed on an\n", + "# \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY\n", + "# KIND, either express or implied. See the License for the\n", + "# specific language governing permissions and limitations\n", + "# under the License.\n", + "\n", + "import inspect\n", + "import json\n", + "import os\n", + "from typing import Callable, Optional\n", + "\n", + "import openai\n", + "import requests\n", + "\n", + "from burr.core import State, action, when\n", + "from burr.core.application import ApplicationBuilder\n", + "\n", + "import os\n", + "import subprocess\n", + "\n", + "# Everything is confined to this directory. See README on why this is not a sandbox.\n", + "WORKSPACE = os.environ.get(\"CODING_AGENT_WORKSPACE\", \"./workspace\")\n", + "\n", + "\n", + "def _resolve(path: str) -> str:\n", + " \"\"\"Resolve a path inside the workspace, refusing anything that escapes it.\"\"\"\n", + " root = os.path.abspath(WORKSPACE)\n", + " full = os.path.abspath(os.path.join(root, path))\n", + " if not full.startswith(root + os.sep) and full != root:\n", + " raise ValueError(f\"path escapes workspace: {path}\")\n", + " return full\n", + "\n", + "\n", + "def list_files(directory: str = \".\") -> dict:\n", + " \"\"\"Lists the files and directories at the given path inside the workspace.\"\"\"\n", + " try:\n", + " target = _resolve(directory)\n", + " return {\"entries\": sorted(os.listdir(target))}\n", + " except (ValueError, OSError) as e:\n", + " return {\"error\": str(e)}\n", + "\n", + "\n", + "def read_file(path: str) -> dict:\n", + " \"\"\"Reads a text file from the workspace and returns its contents.\"\"\"\n", + " try:\n", + " with open(_resolve(path)) as f:\n", + " return {\"path\": path, \"contents\": f.read()}\n", + " except (ValueError, OSError) as e:\n", + " return {\"error\": str(e)}\n", + "\n", + "\n", + "def write_file(path: str, contents: str) -> dict:\n", + " \"\"\"Writes text to a file in the workspace, creating or overwriting it.\"\"\"\n", + " try:\n", + " full = _resolve(path)\n", + " os.makedirs(os.path.dirname(full), exist_ok=True)\n", + " with open(full, \"w\") as f:\n", + " f.write(contents)\n", + " return {\"path\": path, \"bytes_written\": len(contents)}\n", + " except (ValueError, OSError) as e:\n", + " return {\"error\": str(e)}\n", + "\n", + "\n", + "def run_bash(command: str) -> dict:\n", + " \"\"\"Runs a shell command in the workspace and returns its output.\"\"\"\n", + " try:\n", + " proc = subprocess.run(\n", + " command, shell=True, cwd=os.path.abspath(WORKSPACE),\n", + " capture_output=True, text=True, timeout=30,\n", + " )\n", + " return {\n", + " \"exit_code\": proc.returncode,\n", + " \"stdout\": proc.stdout[-4000:],\n", + " \"stderr\": proc.stderr[-4000:],\n", + " }\n", + " except subprocess.TimeoutExpired:\n", + " return {\"error\": \"command timed out after 30s\"}\n", + " " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Describing the tools to the model\n", + "\n", + "The schema is derived from signatures and docstrings, so the tools stay the single source of truth. Only parameters without defaults are marked required." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "dfc10623", + "metadata": {}, + "outputs": [], + "source": [ + "TOOLS = {\n", + " \"list_files\": list_files,\n", + " \"read_file\": read_file,\n", + " \"write_file\": write_file,\n", + " \"run_bash\": run_bash,\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "TYPE_MAP = {str: \"string\", int: \"integer\", float: \"number\", bool: \"boolean\"}\n", + "\n", + "OPENAI_TOOLS = [\n", + " {\n", + " \"type\": \"function\",\n", + " \"function\": {\n", + " \"name\": name,\n", + " \"description\": fn.__doc__ or name,\n", + " \"parameters\": {\n", + " \"type\": \"object\",\n", + " \"properties\": {\n", + " p.name: {\n", + " \"type\": TYPE_MAP.get(p.annotation, \"string\"),\n", + " \"description\": p.name,\n", + " }\n", + " for p in inspect.signature(fn).parameters.values()\n", + " },\n", + " \"required\": [\n", + " p.name\n", + " for p in inspect.signature(fn).parameters.values()\n", + " if p.default is inspect.Parameter.empty\n", + " ],\n", + " },\n", + " },\n", + " }\n", + " for name, fn in TOOLS.items()\n", + "]" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "SYSTEM_PROMPT = (\n", + " \"You are a coding agent working inside a project directory. \"\n", + " \"Use the tools to inspect and modify files, and to run commands. \"\n", + " \"Work one step at a time: look before you edit, and verify changes by running them. \"\n", + " \"When the task is complete, reply with a short summary and request no tool.\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Two clients\n", + "\n", + "Both return the same shape, so the rest of the harness doesn't care which is in\n", + "use. The scripted client replays a fixed sequence, which is what lets this\n", + "notebook -- and CI -- run with no API key." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "class OpenAIClient:\n", + " \"\"\"Calls OpenAI's chat completions API with tool calling enabled.\"\"\"\n", + "\n", + " def __init__(self, model: str = \"gpt-4o\"):\n", + " self.model = model\n", + "\n", + " def __call__(self, messages: list[dict]) -> dict:\n", + " import openai\n", + "\n", + " response = openai.chat.completions.create(\n", + " model=self.model, messages=messages, tools=OPENAI_TOOLS\n", + " )\n", + " message = response.choices[0].message\n", + " calls = [\n", + " {\"id\": c.id, \"name\": c.function.name, \"args\": json.loads(c.function.arguments)}\n", + " for c in (message.tool_calls or [])\n", + " ]\n", + " return {\"content\": message.content, \"tool_calls\": calls}\n", + "\n", + "\n", + "class ScriptedClient:\n", + " \"\"\"Replays a fixed list of responses. Lets the example run without an API key.\"\"\"\n", + "\n", + " def __init__(self, responses: list[dict]):\n", + " self.responses = list(responses)\n", + " self.index = 0\n", + "\n", + " def __call__(self, messages: list[dict]) -> dict:\n", + " if self.index >= len(self.responses):\n", + " return {\"content\": \"Script exhausted.\", \"tool_calls\": []}\n", + " response = self.responses[self.index]\n", + " self.index += 1\n", + " return response\n", + "\n", + "\n", + "DEFAULT_SCRIPT = [\n", + " {\"content\": None, \"tool_calls\": [{\"id\": \"c1\", \"name\": \"list_files\", \"args\": {}}]},\n", + " {\n", + " \"content\": None,\n", + " \"tool_calls\": [\n", + " {\"id\": \"c2\", \"name\": \"write_file\",\n", + " \"args\": {\"path\": \"hello.py\", \"contents\": \"print('hello from the agent')\\n\"}}\n", + " ],\n", + " },\n", + " {\"content\": None, \"tool_calls\": [{\"id\": \"c3\", \"name\": \"run_bash\",\n", + " \"args\": {\"command\": \"python hello.py\"}}]},\n", + " {\"content\": \"Created hello.py and confirmed it runs.\", \"tool_calls\": []},\n", + "]\n", + "\n", + "\n", + "def get_client():\n", + " \"\"\"Real client when OPENAI_API_KEY is set, otherwise the scripted stand-in.\"\"\"\n", + " if os.environ.get(\"OPENAI_API_KEY\"):\n", + " return OpenAIClient()\n", + " return ScriptedClient(DEFAULT_SCRIPT)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# The actions\n", + "\n", + "`call_llm` is the interesting one. It appends the model's reply to `messages`,\n", + "and either sets `done` (no tool requested) or records which tool to run next.\n", + "Tool results come back as `role: \"tool\"` messages carrying the `tool_call_id`\n", + "they answer -- that is how the model sees what its last action returned." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "@action(reads=[], writes=[\"task\", \"messages\", \"steps\", \"done\", \"final_answer\"])\n", + "def human_input(state: State, task: str) -> State:\n", + " \"\"\"Takes a task from the user and starts a fresh run.\"\"\"\n", + " return state.update(\n", + " task=task,\n", + " messages=[\n", + " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n", + " {\"role\": \"user\", \"content\": task},\n", + " ],\n", + " steps=0,\n", + " done=False,\n", + " final_answer=None,\n", + " )\n", + "\n", + "\n", + "@action(reads=[\"messages\"], writes=[\"messages\"])\n", + "def create_prompt(state: State) -> State:\n", + " \"\"\"Seam for shaping the prompt before each call -- add file trees, summaries, etc.\"\"\"\n", + " return state.update(messages=state[\"messages\"])\n", + "\n", + "\n", + "@action(\n", + " reads=[\"messages\", \"steps\"],\n", + " writes=[\"messages\", \"next_tool\", \"next_args\", \"last_tool_call_id\",\n", + " \"steps\", \"done\", \"final_answer\"],\n", + ")\n", + "def call_llm(state: State, client: Callable) -> State:\n", + " \"\"\"Asks the model what to do next: call a tool, or finish.\"\"\"\n", + " result = client(state[\"messages\"])\n", + " calls = result[\"tool_calls\"]\n", + "\n", + " if not calls:\n", + " return state.update(\n", + " messages=state[\"messages\"] + [{\"role\": \"assistant\", \"content\": result[\"content\"]}],\n", + " next_tool=None,\n", + " next_args={},\n", + " last_tool_call_id=None,\n", + " steps=state[\"steps\"] + 1,\n", + " done=True,\n", + " final_answer=result[\"content\"],\n", + " )\n", + "\n", + " # One tool per iteration keeps the graph readable; extras are dropped.\n", + " call = calls[0]\n", + " assistant_message = {\n", + " \"role\": \"assistant\",\n", + " \"content\": result[\"content\"],\n", + " \"tool_calls\": [\n", + " {\n", + " \"id\": call[\"id\"],\n", + " \"type\": \"function\",\n", + " \"function\": {\"name\": call[\"name\"], \"arguments\": json.dumps(call[\"args\"])},\n", + " }\n", + " ],\n", + " }\n", + " return state.update(\n", + " messages=state[\"messages\"] + [assistant_message],\n", + " next_tool=call[\"name\"],\n", + " next_args=call[\"args\"],\n", + " last_tool_call_id=call[\"id\"],\n", + " steps=state[\"steps\"] + 1,\n", + " done=False,\n", + " final_answer=None,\n", + " )\n", + "\n", + "\n", + "@action(reads=[\"next_args\", \"last_tool_call_id\", \"messages\"], writes=[\"messages\"])\n", + "def execute_tool(state: State, tool_function: Callable) -> State:\n", + " \"\"\"Runs one tool and feeds its result back to the model.\"\"\"\n", + " result = tool_function(**state[\"next_args\"])\n", + " return state.update(\n", + " messages=state[\"messages\"]\n", + " + [\n", + " {\n", + " \"role\": \"tool\",\n", + " \"tool_call_id\": state[\"last_tool_call_id\"],\n", + " \"content\": json.dumps(result),\n", + " }\n", + " ]\n", + " )\n", + "\n", + "\n", + "@action(reads=[\"final_answer\", \"steps\", \"max_steps\"], writes=[\"final_answer\"])\n", + "def respond(state: State) -> State:\n", + " \"\"\"Surfaces the answer, or explains that the budget ran out.\"\"\"\n", + " if state[\"final_answer\"]:\n", + " return state.update(final_answer=state[\"final_answer\"])\n", + " return state.update(\n", + " final_answer=f\"Stopped after {state['steps']} steps without finishing the task.\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Wiring the graph\n", + "\n", + "Two transitions leave `call_llm` for `respond`: the model finished, or the step\n", + "budget ran out. Both are listed **before** the tool transitions, because Burr\n", + "takes the first matching condition. An agent that picks its own next step can\n", + "loop forever, so the budget is not optional.\n", + "\n", + "Each tool is a separately bound action, so the Burr UI shows exactly which one\n", + "ran at each step." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "def application(app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None):\n", + " \"\"\"Builds the coding agent application.\"\"\"\n", + " client = client or get_client()\n", + " return (\n", + " ApplicationBuilder()\n", + " .with_actions(\n", + " human_input,\n", + " create_prompt,\n", + " respond,\n", + " call_llm=call_llm.bind(client=client),\n", + " read_file=execute_tool.bind(tool_function=read_file),\n", + " write_file=execute_tool.bind(tool_function=write_file),\n", + " list_files=execute_tool.bind(tool_function=list_files),\n", + " run_bash=execute_tool.bind(tool_function=run_bash),\n", + " )\n", + " .with_transitions(\n", + " (\"human_input\", \"create_prompt\"),\n", + " (\"create_prompt\", \"call_llm\"),\n", + " (\"call_llm\", \"respond\", when(done=True)),\n", + " (\"call_llm\", \"respond\", expr(\"steps>=max_steps\")),\n", + " (\"call_llm\", \"read_file\", when(next_tool=\"read_file\")),\n", + " (\"call_llm\", \"write_file\", when(next_tool=\"write_file\")),\n", + " (\"call_llm\", \"list_files\", when(next_tool=\"list_files\")),\n", + " (\"call_llm\", \"run_bash\", when(next_tool=\"run_bash\")),\n", + " ([\"read_file\", \"write_file\", \"list_files\", \"run_bash\"], \"call_llm\"),\n", + " (\"respond\", \"human_input\"),\n", + " )\n", + " .with_state(max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None)\n", + " .with_identifiers(app_id=app_id)\n", + " .with_entrypoint(\"human_input\")\n", + " .with_tracker(project=\"demo_coding_agent\")\n", + " .build()\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Running it" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "%3\n", + "\n", + "\n", + "\n", + "human_input\n", + "\n", + "human_input\n", + "\n", + "\n", + "\n", + "create_prompt\n", + "\n", + "create_prompt\n", + "\n", + "\n", + "\n", + "human_input->create_prompt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "input__task\n", + "\n", + "input: task\n", + "\n", + "\n", + "\n", + "input__task->human_input\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "call_llm\n", + "\n", + "call_llm\n", + "\n", + "\n", + "\n", + "create_prompt->call_llm\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "respond\n", + "\n", + "respond\n", + "\n", + "\n", + "\n", + "respond->human_input\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "call_llm->respond\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "call_llm->respond\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "read_file\n", + "\n", + "read_file\n", + "\n", + "\n", + "\n", + "call_llm->read_file\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "write_file\n", + "\n", + "write_file\n", + "\n", + "\n", + "\n", + "call_llm->write_file\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "list_files\n", + "\n", + "list_files\n", + "\n", + "\n", + "\n", + "call_llm->list_files\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "run_bash\n", + "\n", + "run_bash\n", + "\n", + "\n", + "\n", + "call_llm->run_bash\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "read_file->call_llm\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "write_file->call_llm\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "list_files->call_llm\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "run_bash->call_llm\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "app = application()\n", + "app.visualize()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Created hello.py and confirmed it runs.\n" + ] + } + ], + "source": [ + "_, _, state = app.run(\n", + " halt_after=[\"respond\"],\n", + " inputs={\"task\": \"Create a hello.py that prints a greeting, then run it.\"},\n", + ")\n", + "print(state[\"final_answer\"])" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv (3.12.1.final.0)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.1" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/coding-agent/requirements.txt b/examples/coding-agent/requirements.txt new file mode 100644 index 000000000..40d0c6a61 --- /dev/null +++ b/examples/coding-agent/requirements.txt @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +burr +openai \ No newline at end of file From 68eaf2b6530cd5b29c9cc1d80999c695309f110f Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:56:45 +0800 Subject: [PATCH 3/7] fix(examples): make the coding-agent harness self-correcting Address the review findings on the coding-agent example: - Drop the unused imports in tools.py; `requests` was not in requirements.txt, so a clean install failed to import at all. - Route tool actions back through create_prompt, so the prompt-shaping seam runs before every model call rather than only the first. - Report an unknown tool name back to the model as a tool error instead of leaving call_llm with no matching transition. - Catch malformed tool-call JSON in OpenAIClient and hand it to the model as an error rather than raising. - Wrap tool execution in try/except so a raising tool becomes an error result the model can react to. - Create the workspace before running a command, so a model that starts with run_bash does not hit FileNotFoundError. - Resolve paths with realpath so a symlink inside the workspace cannot be followed out of it. - Skip the graphviz render when the binary is unavailable, instead of crashing before the example runs. - Add the ASF license header to README.md. --- examples/coding-agent/README.md | 19 ++++ examples/coding-agent/application.py | 124 ++++++++++++++++++++++----- examples/coding-agent/tools.py | 37 ++++---- 3 files changed, 142 insertions(+), 38 deletions(-) diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index d37c5c097..a09813e0d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,3 +1,22 @@ + + # Coding agent A minimal coding agent harness: an LLM that reads files, writes files, and runs diff --git a/examples/coding-agent/application.py b/examples/coding-agent/application.py index 5a66c0ca9..9d710e946 100644 --- a/examples/coding-agent/application.py +++ b/examples/coding-agent/application.py @@ -21,11 +21,11 @@ import os from typing import Callable, Optional +from tools import list_files, read_file, run_bash, write_file + from burr.core import State, action, expr, when from burr.core.application import ApplicationBuilder -from tools import list_files, read_file, run_bash, write_file - TOOLS = { "list_files": list_files, "read_file": read_file, @@ -87,10 +87,23 @@ def __call__(self, messages: list[dict]) -> dict: model=self.model, messages=messages, tools=OPENAI_TOOLS ) message = response.choices[0].message - calls = [ - {"id": c.id, "name": c.function.name, "args": json.loads(c.function.arguments)} - for c in (message.tool_calls or []) - ] + calls = [] + for c in message.tool_calls or []: + try: + args = json.loads(c.function.arguments) + except json.JSONDecodeError as e: + # Models occasionally emit malformed arguments. Carry the error + # through as a tool result instead of crashing the run. + calls.append( + { + "id": c.id, + "name": c.function.name, + "args": {}, + "error": f"could not parse tool arguments as JSON: {e}", + } + ) + else: + calls.append({"id": c.id, "name": c.function.name, "args": args}) return {"content": message.content, "tool_calls": calls} @@ -114,12 +127,22 @@ def __call__(self, messages: list[dict]) -> dict: { "content": None, "tool_calls": [ - {"id": "c2", "name": "write_file", - "args": {"path": "hello.py", "contents": "print('hello from the agent')\n"}} + { + "id": "c2", + "name": "write_file", + "args": { + "path": "hello.py", + "contents": "print('hello from the agent')\n", + }, + } + ], + }, + { + "content": None, + "tool_calls": [ + {"id": "c3", "name": "run_bash", "args": {"command": "python hello.py"}} ], }, - {"content": None, "tool_calls": [{"id": "c3", "name": "run_bash", - "args": {"command": "python hello.py"}}]}, {"content": "Created hello.py and confirmed it runs.", "tool_calls": []}, ] @@ -157,8 +180,15 @@ def create_prompt(state: State) -> State: @action( reads=["messages", "steps"], - writes=["messages", "next_tool", "next_args", "last_tool_call_id", - "steps", "done", "final_answer"], + writes=[ + "messages", + "next_tool", + "next_args", + "last_tool_call_id", + "steps", + "done", + "final_answer", + ], ) def call_llm(state: State, client: Callable) -> State: """Asks the model what to do next: call a tool, or finish.""" @@ -167,7 +197,8 @@ def call_llm(state: State, client: Callable) -> State: if not calls: return state.update( - messages=state["messages"] + [{"role": "assistant", "content": result["content"]}], + messages=state["messages"] + + [{"role": "assistant", "content": result["content"]}], next_tool=None, next_args={}, last_tool_call_id=None, @@ -185,10 +216,39 @@ def call_llm(state: State, client: Callable) -> State: { "id": call["id"], "type": "function", - "function": {"name": call["name"], "arguments": json.dumps(call["args"])}, + "function": { + "name": call["name"], + "arguments": json.dumps(call["args"]), + }, } ], } + + # A tool the model invented, or arguments that failed to parse, would leave + # the run with no matching transition. Answer the call with an error and go + # back around the loop, so the model gets a chance to correct itself. + problem = call.get("error") + if problem is None and call["name"] not in TOOLS: + problem = f"unknown tool: {call['name']}" + if problem is not None: + return state.update( + messages=state["messages"] + + [ + assistant_message, + { + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps({"error": problem}), + }, + ], + next_tool=None, + next_args={}, + last_tool_call_id=None, + steps=state["steps"] + 1, + done=False, + final_answer=None, + ) + return state.update( messages=state["messages"] + [assistant_message], next_tool=call["name"], @@ -202,8 +262,16 @@ def call_llm(state: State, client: Callable) -> State: @action(reads=["next_args", "last_tool_call_id", "messages"], writes=["messages"]) def execute_tool(state: State, tool_function: Callable) -> State: - """Runs one tool and feeds its result back to the model.""" - result = tool_function(**state["next_args"]) + """Runs one tool and feeds its result back to the model. + + A tool that raises is reported back to the model as an error rather than + ending the run -- the point of the loop is that the agent sees the failure + and tries something else. + """ + try: + result = tool_function(**state["next_args"]) + except Exception as e: # noqa: BLE001 - deliberately broad; the model recovers + result = {"error": f"{type(e).__name__}: {e}"} return state.update( messages=state["messages"] + [ @@ -226,7 +294,9 @@ def respond(state: State) -> State: ) -def application(app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None): +def application( + app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None +): """Builds the coding agent application.""" client = client or get_client() return ( @@ -246,14 +316,21 @@ def application(app_id: Optional[str] = None, max_steps: int = 15, client: Calla ("create_prompt", "call_llm"), ("call_llm", "respond", when(done=True)), ("call_llm", "respond", expr("steps>=max_steps")), + # A bad tool call is answered with an error and loops back so the + # model can retry; this must come before the tool transitions. + ("call_llm", "create_prompt", when(next_tool=None)), ("call_llm", "read_file", when(next_tool="read_file")), ("call_llm", "write_file", when(next_tool="write_file")), ("call_llm", "list_files", when(next_tool="list_files")), ("call_llm", "run_bash", when(next_tool="run_bash")), - (["read_file", "write_file", "list_files", "run_bash"], "call_llm"), + # Tool results go back through create_prompt, so the prompt-shaping + # seam runs before every model call rather than only the first. + (["read_file", "write_file", "list_files", "run_bash"], "create_prompt"), ("respond", "human_input"), ) - .with_state(max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None) + .with_state( + max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None + ) .with_identifiers(app_id=app_id) .with_entrypoint("human_input") .with_tracker(project="demo_coding_agent") @@ -263,9 +340,14 @@ def application(app_id: Optional[str] = None, max_steps: int = 15, client: Calla if __name__ == "__main__": app = application() - app.visualize(output_file_path="./statemachine.png") + # Rendering the graph needs the Graphviz binary, which the example does not + # otherwise require. Skip it when unavailable instead of failing the run. + try: + app.visualize(output_file_path="./statemachine.png") + except Exception as e: # noqa: BLE001 + print(f"Skipping state machine render (Graphviz unavailable: {e})") _, _, state = app.run( halt_after=["respond"], inputs={"task": "Create a hello.py that prints a greeting, then run it."}, ) - print(state["final_answer"]) \ No newline at end of file + print(state["final_answer"]) diff --git a/examples/coding-agent/tools.py b/examples/coding-agent/tools.py index 81c6d45eb..358e3ff08 100644 --- a/examples/coding-agent/tools.py +++ b/examples/coding-agent/tools.py @@ -16,17 +16,6 @@ # specific language governing permissions and limitations # under the License. -import inspect -import json -import os -from typing import Callable, Optional - -import openai -import requests - -from burr.core import State, action, when -from burr.core.application import ApplicationBuilder - import os import subprocess @@ -35,9 +24,13 @@ def _resolve(path: str) -> str: - """Resolve a path inside the workspace, refusing anything that escapes it.""" - root = os.path.abspath(WORKSPACE) - full = os.path.abspath(os.path.join(root, path)) + """Resolve a path inside the workspace, refusing anything that escapes it. + + Resolves symlinks (``realpath`` rather than ``abspath``) so that a link + placed inside the workspace cannot be followed out of it. + """ + root = os.path.realpath(WORKSPACE) + full = os.path.realpath(os.path.join(root, path)) if not full.startswith(root + os.sep) and full != root: raise ValueError(f"path escapes workspace: {path}") return full @@ -76,9 +69,17 @@ def write_file(path: str, contents: str) -> dict: def run_bash(command: str) -> dict: """Runs a shell command in the workspace and returns its output.""" try: + workspace = os.path.realpath(WORKSPACE) + # The workspace may not exist yet -- a model that starts by running a + # command rather than writing a file should still get a useful answer. + os.makedirs(workspace, exist_ok=True) proc = subprocess.run( - command, shell=True, cwd=os.path.abspath(WORKSPACE), - capture_output=True, text=True, timeout=30, + command, + shell=True, + cwd=workspace, + capture_output=True, + text=True, + timeout=30, ) return { "exit_code": proc.returncode, @@ -86,4 +87,6 @@ def run_bash(command: str) -> dict: "stderr": proc.stderr[-4000:], } except subprocess.TimeoutExpired: - return {"error": "command timed out after 30s"} \ No newline at end of file + return {"error": "command timed out after 30s"} + except OSError as e: + return {"error": str(e)} From 34e942a751bf4b4378aae878db7d8a2d5f35510e Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:56:45 +0800 Subject: [PATCH 4/7] test(examples): add tests for the coding-agent harness Cover the scripted happy path, step-budget termination, unknown tools, malformed tool arguments, tool failures, and workspace confinement for reads and writes. Driven by ScriptedClient, so no API key is needed, and tools.WORKSPACE is patched to tmp_path so tests never touch a real ./workspace. --- examples/coding-agent/test_coding_agent.py | 243 +++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 examples/coding-agent/test_coding_agent.py diff --git a/examples/coding-agent/test_coding_agent.py b/examples/coding-agent/test_coding_agent.py new file mode 100644 index 000000000..ba682061f --- /dev/null +++ b/examples/coding-agent/test_coding_agent.py @@ -0,0 +1,243 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the coding agent example. + +The agent is driven by a `ScriptedClient`, so the whole state machine -- tool +dispatch, error recovery and the step budget -- is exercised without an API key. +""" + +import json + +import pytest +import tools +from application import ScriptedClient, application + + +@pytest.fixture(autouse=True) +def workspace(tmp_path, monkeypatch): + """Points the tools at an isolated directory for each test. + + ``tools.WORKSPACE`` is read when a tool is called, so patching the module + attribute is enough -- tests never touch the real ``./workspace``. + """ + monkeypatch.setattr(tools, "WORKSPACE", str(tmp_path)) + return tmp_path + + +def run(task: str, script: list[dict], max_steps: int = 15, tmp_path=None): + """Runs the agent with a fixed script and returns the final state.""" + client = ScriptedClient(script) + app = application(max_steps=max_steps, client=client) + _, _, state = app.run(halt_after=["respond"], inputs={"task": task}) + return state + + +def tool_result(state: dict, index: int = -1) -> dict: + """Returns the JSON payload of the `index`-th tool message in the history.""" + tool_messages = [m for m in state["messages"] if m["role"] == "tool"] + return json.loads(tool_messages[index]["content"]) + + +def test_happy_path_runs_to_completion(tmp_path, monkeypatch): + """A scripted run calls tools, then stops when the model requests no tool.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "list_files", "args": {}}], + }, + { + "content": None, + "tool_calls": [ + { + "id": "c2", + "name": "write_file", + "args": {"path": "hello.py", "contents": "print('hi')\n"}, + } + ], + }, + {"content": "Done.", "tool_calls": []}, + ] + state = run("write hello.py", script) + + assert state["done"] is True + assert state["final_answer"] == "Done." + # three model calls, each counted once + assert state["steps"] == 3 + assert (tmp_path / "hello.py").read_text() == "print('hi')\n" + # every tool call got exactly one result message + assert len([m for m in state["messages"] if m["role"] == "tool"]) == 2 + + +def test_step_budget_terminates_the_run(tmp_path, monkeypatch): + """The agent stops once `max_steps` is reached instead of looping forever.""" + monkeypatch.chdir(tmp_path) + # a script that would never terminate on its own + script = [ + { + "content": None, + "tool_calls": [{"id": f"c{i}", "name": "list_files", "args": {}}], + } + for i in range(10) + ] + state = run("list forever", script, max_steps=3) + + assert state["steps"] == 3 + assert "without finishing" in state["final_answer"] + assert "3 steps" in state["final_answer"] + + +def test_unknown_tool_is_reported_back_to_the_model(tmp_path, monkeypatch): + """An invented tool name becomes an error result and the loop continues.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "delete_everything", "args": {}}], + }, + {"content": "Recovered.", "tool_calls": []}, + ] + state = run("do something", script) + + assert state["done"] is True + assert state["final_answer"] == "Recovered." + assert "unknown tool" in tool_result(state)["error"] + # the failed call did not abort the run + assert state["steps"] == 2 + + +def test_malformed_tool_arguments_do_not_crash_the_run(tmp_path, monkeypatch): + """Arguments that failed to parse surface as an error the model can react to.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [ + { + "id": "c1", + "name": "write_file", + "args": {}, + "error": "could not parse tool arguments as JSON", + } + ], + }, + {"content": "Fixed.", "tool_calls": []}, + ] + state = run("write a file", script) + + assert state["done"] is True + assert "could not parse" in tool_result(state)["error"] + + +def test_tool_failure_is_reported_back_to_the_model(tmp_path, monkeypatch): + """A tool that raises returns an error result rather than killing the run.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [ + { + "id": "c1", + "name": "read_file", + "args": {"path": "does-not-exist.txt"}, + } + ], + }, + {"content": "That file is missing.", "tool_calls": []}, + ] + state = run("read a file", script) + + assert state["done"] is True + assert state["final_answer"] == "That file is missing." + assert "error" in tool_result(state) + + +@pytest.mark.parametrize( + "path", + ["../outside.txt", "sub/../../outside.txt", "/etc/passwd"], +) +def test_reads_are_confined_to_the_workspace(tmp_path, monkeypatch, path): + """Paths that escape the project directory are refused.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "read_file", "args": {"path": path}}], + }, + {"content": "Stopped.", "tool_calls": []}, + ] + state = run("read outside", script) + + result = tool_result(state) + assert "error" in result + assert ( + "outside" in result["error"].lower() or "workspace" in result["error"].lower() + ) + + +def test_writes_are_confined_to_the_workspace(tmp_path, monkeypatch): + """A write aimed outside the project directory is refused, and lands nowhere.""" + monkeypatch.chdir(tmp_path) + script = [ + { + "content": None, + "tool_calls": [ + { + "id": "c1", + "name": "write_file", + "args": {"path": "../escaped.txt", "contents": "nope"}, + } + ], + }, + {"content": "Stopped.", "tool_calls": []}, + ] + state = run("write outside", script) + + assert "error" in tool_result(state) + assert not (tmp_path.parent / "escaped.txt").exists() + + +def test_tool_results_are_fed_back_to_the_model(tmp_path, monkeypatch): + """The scripted client sees each tool result before deciding what to do next.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "data.txt").write_text("hello") + seen = [] + + class RecordingClient: + def __init__(self): + self.index = 0 + + def __call__(self, messages): + seen.append([m["role"] for m in messages]) + self.index += 1 + if self.index == 1: + return { + "content": None, + "tool_calls": [ + {"id": "c1", "name": "read_file", "args": {"path": "data.txt"}} + ], + } + return {"content": "Read it.", "tool_calls": []} + + app = application(client=RecordingClient()) + _, _, state = app.run(halt_after=["respond"], inputs={"task": "read data.txt"}) + + assert state["final_answer"] == "Read it." + # the second model call must include the tool result from the first + assert "tool" in seen[1] + assert tool_result(state)["contents"] == "hello" From c14bece9f9a52218b902f41010c134ae21e5bab3 Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:32:45 +0800 Subject: [PATCH 5/7] fix(coding-agent): handle non-string tool arguments and case-insensitive paths Two defects in the new example, both found in review: - `json.loads(None)` raises TypeError, which `except JSONDecodeError` did not catch. A backend that sends `arguments=None` (some OpenAI-compatible providers do when streaming fails to assemble the call) would therefore abort the run -- exactly the failure this block was added to prevent. Non-string arguments are now rejected with a clear message. - `_resolve` compared a realpath-derived path against WORKSPACE with a plain string prefix test. On case-insensitive filesystems the two can differ in case, so a legitimate path could be rejected as an escape. The comparison is now normcased, and `rstrip` handles the drive-root case where `root + os.sep` would double the separator. Also corrects the transition-order comment: the `next_tool=None` branch must come *after* the `done=True` branch (a finished run has no tool calls, so both conditions hold and the first match in insertion order wins), not merely "before the tool transitions". Tests: the existing malformed-arguments test hand-wrote the `error` field, so the parsing path had no coverage at all. Added tests driving OpenAIClient through a stand-in `openai` module (None + invalid JSON), plus workspace confinement edges (root accepted, sibling sharing the prefix rejected, case differences tolerated). 16 passed. --- examples/coding-agent/application.py | 27 +++++- examples/coding-agent/test_coding_agent.py | 101 ++++++++++++++++++++- examples/coding-agent/tools.py | 10 +- 3 files changed, 131 insertions(+), 7 deletions(-) diff --git a/examples/coding-agent/application.py b/examples/coding-agent/application.py index 9d710e946..e85533c0a 100644 --- a/examples/coding-agent/application.py +++ b/examples/coding-agent/application.py @@ -71,7 +71,11 @@ # --- LLM clients ----------------------------------------------------------- # Both return the same shape: -# {"content": str | None, "tool_calls": [{"id", "name", "args"}]} +# {"content": str | None, +# "tool_calls": [{"id", "name", "args", "error"?}]} +# `error` is present (and `args` left empty) when the model emitted arguments +# that could not be parsed as JSON; the harness turns it into a tool result +# rather than crashing the run. class OpenAIClient: @@ -90,8 +94,15 @@ def __call__(self, messages: list[dict]) -> dict: calls = [] for c in message.tool_calls or []: try: - args = json.loads(c.function.arguments) - except json.JSONDecodeError as e: + raw = c.function.arguments + # A JSON string is what the API contract promises, but some + # OpenAI-compatible backends send None when streaming failed to + # assemble the arguments -- json.loads(None) raises TypeError, + # which is exactly the crash this block exists to prevent. + if not isinstance(raw, str): + raise TypeError(f"expected a JSON string, got {type(raw).__name__}") + args = json.loads(raw) + except (json.JSONDecodeError, TypeError) as e: # Models occasionally emit malformed arguments. Carry the error # through as a tool result instead of crashing the run. calls.append( @@ -316,8 +327,14 @@ def application( ("create_prompt", "call_llm"), ("call_llm", "respond", when(done=True)), ("call_llm", "respond", expr("steps>=max_steps")), - # A bad tool call is answered with an error and loops back so the - # model can retry; this must come before the tool transitions. + # Order matters here in both directions: + # * AFTER the done=True branch above: a finished run has no tool + # calls, so done=True and next_tool=None hold at the same time, + # and Graph.get_next_node returns the first match in insertion + # order. Moving this line up would send a finished run back to + # create_prompt and spin until steps>=max_steps. + # * BEFORE the per-tool transitions below: an unparseable tool + # call is answered with an error so the model can retry. ("call_llm", "create_prompt", when(next_tool=None)), ("call_llm", "read_file", when(next_tool="read_file")), ("call_llm", "write_file", when(next_tool="write_file")), diff --git a/examples/coding-agent/test_coding_agent.py b/examples/coding-agent/test_coding_agent.py index ba682061f..f769fbbce 100644 --- a/examples/coding-agent/test_coding_agent.py +++ b/examples/coding-agent/test_coding_agent.py @@ -22,10 +22,12 @@ """ import json +import os +import sys import pytest import tools -from application import ScriptedClient, application +from application import OpenAIClient, ScriptedClient, application @pytest.fixture(autouse=True) @@ -241,3 +243,100 @@ def __call__(self, messages): # the second model call must include the tool result from the first assert "tool" in seen[1] assert tool_result(state)["contents"] == "hello" + + +# --- OpenAIClient argument parsing ----------------------------------------- +# The scripted-client tests above hand `application` an already-parsed call, +# which means the parsing code itself was never exercised. These tests drive +# `OpenAIClient` with a stand-in `openai` module so the real path is covered. + + +class _FakeFunction: + def __init__(self, name, arguments): + self.name = name + self.arguments = arguments + + +class _FakeToolCall: + def __init__(self, call_id, name, arguments): + self.id = call_id + self.function = _FakeFunction(name, arguments) + + +def _fake_openai(tool_calls): + """Builds a minimal stand-in for the `openai` module.""" + + class _Completions: + @staticmethod + def create(**_kwargs): + message = type( + "Message", (), {"content": None, "tool_calls": tool_calls} + )() + choice = type("Choice", (), {"message": message})() + return type("Response", (), {"choices": [choice]})() + + chat = type("Chat", (), {"completions": _Completions()})() + return type("Module", (), {"chat": chat})() + + +@pytest.mark.parametrize( + "arguments, expected", + [ + # json.loads(None) raises TypeError -- the crash this code exists to stop + (None, "expected a JSON string, got NoneType"), + ("{not json}", "could not parse tool arguments as JSON"), + ('{"path": "a.txt"}', None), + ], +) +def test_openai_client_survives_unparseable_arguments( + monkeypatch, arguments, expected +): + """Bad tool arguments become an error result instead of killing the run.""" + monkeypatch.setitem( + sys.modules, + "openai", + _fake_openai([_FakeToolCall("c1", "write_file", arguments)]), + ) + + call = OpenAIClient()([{"role": "user", "content": "hi"}])["tool_calls"][0] + + assert call["id"] == "c1" and call["name"] == "write_file" + if expected is None: + assert "error" not in call + assert call["args"] == {"path": "a.txt"} + else: + assert expected in call["error"] + assert call["args"] == {} + + +# --- workspace confinement edges ------------------------------------------- + + +def test_resolve_accepts_the_workspace_root(tmp_path, monkeypatch): + """The workspace itself is inside the workspace.""" + monkeypatch.setattr(tools, "WORKSPACE", str(tmp_path)) + assert tools._resolve(".") == os.path.realpath(str(tmp_path)) + + +def test_resolve_rejects_a_sibling_with_a_shared_prefix(tmp_path, monkeypatch): + """`-sibling` must not pass a naive prefix test against ``.""" + monkeypatch.setattr(tools, "WORKSPACE", str(tmp_path)) + sibling = tmp_path.parent / (tmp_path.name + "-sibling") + sibling.mkdir() + + with pytest.raises(ValueError, match="escapes workspace"): + tools._resolve(os.path.relpath(sibling, tmp_path)) + + +def test_resolve_tolerates_case_differences(tmp_path, monkeypatch): + """`realpath` may return a different case than WORKSPACE (Windows, macOS). + + A plain string prefix test rejects a legitimate path in that situation, so + the comparison is normcased. Skipped where the filesystem is case-sensitive. + """ + swapped = str(tmp_path).swapcase() + if swapped == str(tmp_path) or not os.path.isdir(swapped): + pytest.skip("needs a case-insensitive filesystem") + + monkeypatch.setattr(tools, "WORKSPACE", swapped) + assert tools._resolve("sub") == os.path.join(os.path.realpath(str(tmp_path)), "sub") diff --git a/examples/coding-agent/tools.py b/examples/coding-agent/tools.py index 358e3ff08..233b56613 100644 --- a/examples/coding-agent/tools.py +++ b/examples/coding-agent/tools.py @@ -28,10 +28,18 @@ def _resolve(path: str) -> str: Resolves symlinks (``realpath`` rather than ``abspath``) so that a link placed inside the workspace cannot be followed out of it. + + The comparison is done on ``normcase``-d paths: on case-insensitive file + systems (Windows, macOS) ``realpath`` may return a different case than + ``WORKSPACE``, and a plain string prefix test would then reject a perfectly + legitimate path. ``rstrip`` on the root handles the drive-root case, where + ``root + os.sep`` would otherwise become a doubled separator (``C:\\``). """ root = os.path.realpath(WORKSPACE) full = os.path.realpath(os.path.join(root, path)) - if not full.startswith(root + os.sep) and full != root: + root_key = os.path.normcase(root) + full_key = os.path.normcase(full) + if full_key != root_key and not full_key.startswith(root_key.rstrip("\\/") + os.sep): raise ValueError(f"path escapes workspace: {path}") return full From 1156470bdc58bcc17256e836cca25822a859c3b0 Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:46:07 +0800 Subject: [PATCH 6/7] fix(coding-agent): compare workspace containment per path component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.path.normcase is a no-op on POSIX, so the documented macOS coverage was not actually provided by it; on macOS realpath already reports the on-disk case. os.path.commonpath compares components and is case-insensitive exactly where the platform is. The containment rule moves into _is_within so it can be asserted directly instead of through the filesystem — the old test passed on macOS without exercising it. --- examples/coding-agent/test_coding_agent.py | 48 ++++++++++++++++++++-- examples/coding-agent/tools.py | 42 +++++++++++++++---- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/examples/coding-agent/test_coding_agent.py b/examples/coding-agent/test_coding_agent.py index f769fbbce..333b96f6e 100644 --- a/examples/coding-agent/test_coding_agent.py +++ b/examples/coding-agent/test_coding_agent.py @@ -312,6 +312,46 @@ def test_openai_client_survives_unparseable_arguments( # --- workspace confinement edges ------------------------------------------- +@pytest.mark.parametrize( + ("root", "full", "expected"), + [ + # the root itself counts as inside + ("/ws", "/ws", True), + # a plain descendant + ("/ws", "/ws/sub", True), + # a sibling sharing a name prefix must NOT pass — this is what a naive + # `startswith(root + os.sep)` test got right but `-sibling` + `..` + # tricks could still confuse + ("/ws", "/ws-sibling", False), + ("/ws", "/ws-sibling/sub", False), + # escaping upwards + ("/ws", "/", False), + ("/ws/sub", "/ws", False), + # unrelated trees have no common ancestor + ("C:\\ws", "D:\\ws\\sub", False), + ], +) +def test_is_within_compares_path_components(root, full, expected): + """Containment is decided per path component, not per character. + + Tested through `_is_within` rather than `_resolve` so the assertions do not + depend on the case behaviour of the filesystem this suite runs on — the + old test went through `_resolve` and was therefore a false positive on + macOS, where `realpath` already normalises case and `normcase` never ran. + """ + assert tools._is_within(root, full) is expected + + +@pytest.mark.skipif(os.name != "nt", reason="drive-letter comparison is Windows-only") +def test_is_within_is_case_insensitive_on_windows(): + """On Windows the containment check must ignore case. + + `ntpath.commonpath` normcases before comparing, which is the only reason + the case difference below is tolerated. + """ + assert tools._is_within("C:\\WS", "c:\\ws\\sub") is True + + def test_resolve_accepts_the_workspace_root(tmp_path, monkeypatch): """The workspace itself is inside the workspace.""" monkeypatch.setattr(tools, "WORKSPACE", str(tmp_path)) @@ -329,10 +369,12 @@ def test_resolve_rejects_a_sibling_with_a_shared_prefix(tmp_path, monkeypatch): def test_resolve_tolerates_case_differences(tmp_path, monkeypatch): - """`realpath` may return a different case than WORKSPACE (Windows, macOS). + """`realpath` may report a different case than WORKSPACE. - A plain string prefix test rejects a legitimate path in that situation, so - the comparison is normcased. Skipped where the filesystem is case-sensitive. + This exercises the real filesystem, so it is skipped where the filesystem is + case-sensitive. Note what it does NOT prove: on macOS `realpath` already + returns the on-disk case, so this passes there without any normalisation + (see `test_is_within_*` for the containment rule itself). """ swapped = str(tmp_path).swapcase() if swapped == str(tmp_path) or not os.path.isdir(swapped): diff --git a/examples/coding-agent/tools.py b/examples/coding-agent/tools.py index 233b56613..f819adeb4 100644 --- a/examples/coding-agent/tools.py +++ b/examples/coding-agent/tools.py @@ -23,23 +23,49 @@ WORKSPACE = os.environ.get("CODING_AGENT_WORKSPACE", "./workspace") +def _is_within(root: str, full: str) -> bool: + """True when ``full`` is ``root`` itself or a descendant of it. + + Split out from :func:`_resolve` so the containment rule can be tested + directly, without depending on the case behaviour of the filesystem the + test happens to run on. + + ``os.path.commonpath`` is used instead of a string prefix test because it + compares path *components*: ``C:\\ws`` is not an ancestor of + ``C:\\ws-sibling`` even though the latter starts with the former. It is + case-insensitive on Windows (``ntpath`` normcases before comparing) and + case-sensitive on POSIX, which is what each platform's filesystem does. + """ + if full == root: + return True + try: + common = os.path.commonpath([root, full]) + except ValueError: + # Different drives (Windows) or a mix of absolute and relative paths: + # there is no common ancestor, so `full` cannot be inside `root`. + return False + # `commonpath` returns a normalised path (and, on Windows, a normcased + # one), so normalise the root the same way before comparing — otherwise + # `/ws` would not match the `\ws` that `ntpath` hands back. + return os.path.normcase(common) == os.path.normcase(os.path.normpath(root)) + + def _resolve(path: str) -> str: """Resolve a path inside the workspace, refusing anything that escapes it. Resolves symlinks (``realpath`` rather than ``abspath``) so that a link placed inside the workspace cannot be followed out of it. - The comparison is done on ``normcase``-d paths: on case-insensitive file - systems (Windows, macOS) ``realpath`` may return a different case than - ``WORKSPACE``, and a plain string prefix test would then reject a perfectly - legitimate path. ``rstrip`` on the root handles the drive-root case, where - ``root + os.sep`` would otherwise become a doubled separator (``C:\\``). + Containment is decided by :func:`_is_within`, which compares path + components and is case-insensitive exactly where the platform is. Windows + needs that (``realpath`` may return a different case than ``WORKSPACE``); + on macOS ``realpath`` already reports the on-disk case, so both sides agree + without any extra normalisation — ``os.path.normcase`` is a no-op on POSIX + and could not have provided it anyway. """ root = os.path.realpath(WORKSPACE) full = os.path.realpath(os.path.join(root, path)) - root_key = os.path.normcase(root) - full_key = os.path.normcase(full) - if full_key != root_key and not full_key.startswith(root_key.rstrip("\\/") + os.sep): + if not _is_within(root, full): raise ValueError(f"path escapes workspace: {path}") return full From 335189430b62717142dd46780b86d6b24c0b4c29 Mon Sep 17 00:00:00 2001 From: haigou-web <206506176+haigou-web@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:25:47 +0800 Subject: [PATCH 7/7] fix(coding-agent): register example in flit sdist config and satisfy pre-commit The new examples/coding-agent directory was not listed in [tool.flit.sdist], so tests/test_release_config.py::test_examples_include_exclude_coverage failed: flit auto-discovers examples/ as a package and every example directory must be explicitly included or excluded. Follow the existing convention and exclude it from the Apache source release. Also apply black (line-length 100) to application.py and test_coding_agent.py, and add the missing trailing newlines to requirements.txt and README.md, as required by the end-of-file-fixer hook. --- examples/coding-agent/README.md | 2 +- examples/coding-agent/application.py | 15 ++++----------- examples/coding-agent/requirements.txt | 2 +- examples/coding-agent/test_coding_agent.py | 16 ++++------------ pyproject.toml | 1 + 5 files changed, 11 insertions(+), 25 deletions(-) diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index a09813e0d..cdb94b310 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -90,4 +90,4 @@ checking an allowlist -- is the natural next thing to build. - [application.py](application.py) -- the state machine, actions, and LLM clients - [tools.py](tools.py) -- the four tools - [notebook.ipynb](notebook.ipynb) -- the same example, walked through step by step -- [requirements.txt](requirements.txt) -- the environment \ No newline at end of file +- [requirements.txt](requirements.txt) -- the environment diff --git a/examples/coding-agent/application.py b/examples/coding-agent/application.py index e85533c0a..d1ab87be8 100644 --- a/examples/coding-agent/application.py +++ b/examples/coding-agent/application.py @@ -150,9 +150,7 @@ def __call__(self, messages: list[dict]) -> dict: }, { "content": None, - "tool_calls": [ - {"id": "c3", "name": "run_bash", "args": {"command": "python hello.py"}} - ], + "tool_calls": [{"id": "c3", "name": "run_bash", "args": {"command": "python hello.py"}}], }, {"content": "Created hello.py and confirmed it runs.", "tool_calls": []}, ] @@ -208,8 +206,7 @@ def call_llm(state: State, client: Callable) -> State: if not calls: return state.update( - messages=state["messages"] - + [{"role": "assistant", "content": result["content"]}], + messages=state["messages"] + [{"role": "assistant", "content": result["content"]}], next_tool=None, next_args={}, last_tool_call_id=None, @@ -305,9 +302,7 @@ def respond(state: State) -> State: ) -def application( - app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None -): +def application(app_id: Optional[str] = None, max_steps: int = 15, client: Callable = None): """Builds the coding agent application.""" client = client or get_client() return ( @@ -345,9 +340,7 @@ def application( (["read_file", "write_file", "list_files", "run_bash"], "create_prompt"), ("respond", "human_input"), ) - .with_state( - max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None - ) + .with_state(max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None) .with_identifiers(app_id=app_id) .with_entrypoint("human_input") .with_tracker(project="demo_coding_agent") diff --git a/examples/coding-agent/requirements.txt b/examples/coding-agent/requirements.txt index 40d0c6a61..e94d27907 100644 --- a/examples/coding-agent/requirements.txt +++ b/examples/coding-agent/requirements.txt @@ -16,4 +16,4 @@ # under the License. burr -openai \ No newline at end of file +openai diff --git a/examples/coding-agent/test_coding_agent.py b/examples/coding-agent/test_coding_agent.py index 333b96f6e..1e2be74bf 100644 --- a/examples/coding-agent/test_coding_agent.py +++ b/examples/coding-agent/test_coding_agent.py @@ -187,9 +187,7 @@ def test_reads_are_confined_to_the_workspace(tmp_path, monkeypatch, path): result = tool_result(state) assert "error" in result - assert ( - "outside" in result["error"].lower() or "workspace" in result["error"].lower() - ) + assert "outside" in result["error"].lower() or "workspace" in result["error"].lower() def test_writes_are_confined_to_the_workspace(tmp_path, monkeypatch): @@ -230,9 +228,7 @@ def __call__(self, messages): if self.index == 1: return { "content": None, - "tool_calls": [ - {"id": "c1", "name": "read_file", "args": {"path": "data.txt"}} - ], + "tool_calls": [{"id": "c1", "name": "read_file", "args": {"path": "data.txt"}}], } return {"content": "Read it.", "tool_calls": []} @@ -269,9 +265,7 @@ def _fake_openai(tool_calls): class _Completions: @staticmethod def create(**_kwargs): - message = type( - "Message", (), {"content": None, "tool_calls": tool_calls} - )() + message = type("Message", (), {"content": None, "tool_calls": tool_calls})() choice = type("Choice", (), {"message": message})() return type("Response", (), {"choices": [choice]})() @@ -288,9 +282,7 @@ def create(**_kwargs): ('{"path": "a.txt"}', None), ], ) -def test_openai_client_survives_unparseable_arguments( - monkeypatch, arguments, expected -): +def test_openai_client_survives_unparseable_arguments(monkeypatch, arguments, expected): """Bad tool arguments become an error result instead of killing the run.""" monkeypatch.setitem( sys.modules, diff --git a/pyproject.toml b/pyproject.toml index 7fba971cc..7cda7dccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -308,6 +308,7 @@ exclude = [ "examples/validate_examples.py", "examples/fastapi_mount_example.py", "examples/adaptive-crag/**", + "examples/coding-agent/**", "examples/conversational-rag/**", "examples/custom-serde/**", "examples/deployment/**",