From 1da16fc244e36ce614f6933738e3a6b9da67fb79 Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Sun, 16 Aug 2026 02:05:05 +0000 Subject: [PATCH 1/4] 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 c389609475fbf45eee7a8323cd14ef8cf57c4523 Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Sun, 16 Aug 2026 02:37:23 +0000 Subject: [PATCH 2/4] 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 e3d11863ee56367f318e6e4571aa09b9a2c07f29 Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Tue, 13 Oct 2026 01:28:18 +0000 Subject: [PATCH 3/4] fix(examples): make the coding-agent harness self-correcting, add tests Addresses review findings on #886: - drop unused imports from tools.py so the example installs from its own requirements.txt - route tool actions back through create_prompt so the prompt seam runs before every model call, not just the first - report unknown tool names back to the model instead of halting silently, with a default transition as a safety net - catch tool and argument errors in execute_tool and return them as tool messages so the agent can recover - create the workspace at import so run_bash works before any write - resolve paths with realpath so symlinks cannot escape the workspace - guard json.loads in OpenAIClient and visualize() in __main__ - add the ASF license header to README.md - add test_coding_agent.py: 10 tests driven by ScriptedClient, no API key - regenerate notebook.ipynb from the fixed sources --- examples/coding-agent/README.md | 21 +- examples/coding-agent/application.py | 118 +++++-- examples/coding-agent/notebook.ipynb | 389 ++++++++++++--------- examples/coding-agent/test_coding_agent.py | 173 +++++++++ examples/coding-agent/tools.py | 30 +- 5 files changed, 527 insertions(+), 204 deletions(-) create mode 100644 examples/coding-agent/test_coding_agent.py diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index d37c5c097..cdb94b310 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 @@ -71,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 5a66c0ca9..dcdb9eef9 100644 --- a/examples/coding-agent/application.py +++ b/examples/coding-agent/application.py @@ -1,4 +1,3 @@ -"""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 @@ -16,16 +15,22 @@ # specific language governing permissions and limitations # under the License. +"""A minimal coding agent harness built on Burr. + +The agent loops: prompt -> LLM -> tool -> back to the prompt, until the model +answers without requesting a tool or the step budget runs out. +""" + 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 +from burr.core import State, action, default, expr, when +from burr.core.application import ApplicationBuilder + TOOLS = { "list_files": list_files, "read_file": read_file, @@ -87,10 +92,15 @@ 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: + # Models occasionally emit malformed JSON. Pass it through as a + # bad argument so the tool step reports it back to the model. + args = {"__malformed_arguments__": c.function.arguments} + calls.append({"id": c.id, "name": c.function.name, "args": args}) return {"content": message.content, "tool_calls": calls} @@ -114,12 +124,17 @@ 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": []}, ] @@ -151,14 +166,25 @@ def human_input(state: State, task: str) -> State: @action(reads=["messages"], writes=["messages"]) def create_prompt(state: State) -> State: - """Seam for shaping the prompt before each call -- add file trees, summaries, etc.""" + """Seam for shaping the prompt before each call -- add file trees, summaries, etc. + + This runs before every model call, not just the first, so anything added + here is applied on each turn of the loop. + """ return state.update(messages=state["messages"]) @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.""" @@ -185,12 +211,40 @@ 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"]), + }, } ], } + messages = state["messages"] + [assistant_message] + + # A model can ask for a tool that doesn't exist. Tell it so, rather than + # leaving the state machine with no matching transition. + if call["name"] not in TOOLS: + known = ", ".join(sorted(TOOLS)) + messages = messages + [ + { + "role": "tool", + "tool_call_id": call["id"], + "content": json.dumps( + {"error": f"unknown tool {call['name']!r}. Available tools: {known}."} + ), + } + ] + return state.update( + messages=messages, + next_tool=None, + next_args={}, + last_tool_call_id=call["id"], + steps=state["steps"] + 1, + done=False, + final_answer=None, + ) + return state.update( - messages=state["messages"] + [assistant_message], + messages=messages, next_tool=call["name"], next_args=call["args"], last_tool_call_id=call["id"], @@ -202,8 +256,17 @@ 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. + + Tool failures are returned to the model rather than raised, so a bad + argument name or a missing file is something the agent can recover from. + """ + try: + result = tool_function(**state["next_args"]) + except TypeError as e: + result = {"error": f"bad arguments for tool: {e}"} + except Exception as e: + result = {"error": f"{type(e).__name__}: {e}"} return state.update( messages=state["messages"] + [ @@ -244,13 +307,21 @@ def application(app_id: Optional[str] = None, max_steps: int = 15, client: Calla .with_transitions( ("human_input", "create_prompt"), ("create_prompt", "call_llm"), + # Exits come first: Burr takes the first condition that matches. ("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"), + # Unknown tool: the error is already in the messages, so loop back + # and let the model choose again. + ("call_llm", "create_prompt", when(next_tool=None)), + # Safety net -- the machine should never be left with nowhere to go. + ("call_llm", "respond", default), + # Tool results go back through create_prompt, so the 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) @@ -263,9 +334,12 @@ 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") + try: + app.visualize(output_file_path="./statemachine.png") + except Exception as e: + print(f"Skipping state machine diagram ({type(e).__name__}: {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/notebook.ipynb b/examples/coding-agent/notebook.ipynb index ce354a34f..9dabbefd7 100644 --- a/examples/coding-agent/notebook.ipynb +++ b/examples/coding-agent/notebook.ipynb @@ -2,7 +2,8 @@ "cells": [ { "cell_type": "code", - "execution_count": 7, + "execution_count": 1, + "id": "20759632", "metadata": {}, "outputs": [], "source": [ @@ -21,11 +22,12 @@ "# \"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." + "# under the License.\n" ] }, { "cell_type": "markdown", + "id": "7cc2fc1e", "metadata": {}, "source": [ "# Coding agent\n", @@ -43,14 +45,16 @@ }, { "cell_type": "markdown", + "id": "0bf4d3f2", "metadata": {}, "source": [ - "# Imports" + "## Imports" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 2, + "id": "a4dce764", "metadata": {}, "outputs": [], "source": [ @@ -60,70 +64,44 @@ "import subprocess\n", "from typing import Callable, Optional\n", "\n", - "from burr.core import State, action, expr, when\n", + "from burr.core import State, action, default, expr, when\n", "from burr.core.application import ApplicationBuilder" ] }, { "cell_type": "markdown", + "id": "07eb6cd3", "metadata": {}, "source": [ - "# The tools\n", + "## 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." + "Paths are confined to a workspace directory, resolved with `realpath` so a\n", + "symlink can't be used to step outside it. Note that `run_bash` can still `cd`\n", + "out: this is a teaching example, not a sandbox." ] }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 3, + "id": "7a1d2928", "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", + "# The agent may run a command before it has written anything, so make sure the\n", + "# directory exists rather than letting subprocess fail on a missing cwd.\n", + "os.makedirs(WORKSPACE, exist_ok=True)\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", + " root = os.path.realpath(WORKSPACE)\n", + " full = os.path.realpath(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", @@ -163,8 +141,12 @@ " \"\"\"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", + " command,\n", + " shell=True,\n", + " cwd=os.path.abspath(WORKSPACE),\n", + " capture_output=True,\n", + " text=True,\n", + " timeout=30,\n", " )\n", " return {\n", " \"exit_code\": proc.returncode,\n", @@ -172,23 +154,23 @@ " \"stderr\": proc.stderr[-4000:],\n", " }\n", " except subprocess.TimeoutExpired:\n", - " return {\"error\": \"command timed out after 30s\"}\n", - " " + " return {\"error\": \"command timed out after 30s\"}" ] }, { "cell_type": "markdown", + "id": "c5f44684", "metadata": {}, "source": [ - "# Describing the tools to the model\n", + "## 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", + "execution_count": 4, + "id": "9a0bb329", "metadata": {}, "outputs": [], "source": [ @@ -197,15 +179,8 @@ " \"read_file\": read_file,\n", " \"write_file\": write_file,\n", " \"run_bash\": run_bash,\n", - "}" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": {}, - "outputs": [], - "source": [ + "}\n", + "\n", "TYPE_MAP = {str: \"string\", int: \"integer\", float: \"number\", bool: \"boolean\"}\n", "\n", "OPENAI_TOOLS = [\n", @@ -232,15 +207,8 @@ " },\n", " }\n", " for name, fn in TOOLS.items()\n", - "]" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": {}, - "outputs": [], - "source": [ + "]\n", + "\n", "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", @@ -251,18 +219,21 @@ }, { "cell_type": "markdown", + "id": "d9ff818a", "metadata": {}, "source": [ - "# Two clients\n", + "## 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." + "notebook -- and CI -- run with no API key. Malformed tool-call JSON from a real\n", + "model is passed through as a bad argument rather than raising." ] }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 5, + "id": "3aab2ceb", "metadata": {}, "outputs": [], "source": [ @@ -279,10 +250,15 @@ " 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", + " calls = []\n", + " for c in message.tool_calls or []:\n", + " try:\n", + " args = json.loads(c.function.arguments)\n", + " except json.JSONDecodeError:\n", + " # Models occasionally emit malformed JSON. Pass it through as a\n", + " # bad argument so the tool step reports it back to the model.\n", + " args = {\"__malformed_arguments__\": c.function.arguments}\n", + " calls.append({\"id\": c.id, \"name\": c.function.name, \"args\": args})\n", " return {\"content\": message.content, \"tool_calls\": calls}\n", "\n", "\n", @@ -306,12 +282,17 @@ " {\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", + " \"id\": \"c2\",\n", + " \"name\": \"write_file\",\n", + " \"args\": {\"path\": \"hello.py\", \"contents\": \"print('hello from the agent')\\n\"},\n", + " }\n", " ],\n", " },\n", - " {\"content\": None, \"tool_calls\": [{\"id\": \"c3\", \"name\": \"run_bash\",\n", - " \"args\": {\"command\": \"python hello.py\"}}]},\n", + " {\n", + " \"content\": None,\n", + " \"tool_calls\": [{\"id\": \"c3\", \"name\": \"run_bash\", \"args\": {\"command\": \"python hello.py\"}}],\n", + " },\n", " {\"content\": \"Created hello.py and confirmed it runs.\", \"tool_calls\": []},\n", "]\n", "\n", @@ -325,19 +306,25 @@ }, { "cell_type": "markdown", + "id": "e663e547", "metadata": {}, "source": [ - "# The actions\n", + "## 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." + "and either sets `done` (no tool requested), records which tool to run next, or\n", + "-- if the model asked for a tool that doesn't exist -- tells it so and lets it\n", + "choose again.\n", + "\n", + "`execute_tool` catches failures and returns them as tool messages, so a wrong\n", + "argument name or a missing file is something the agent recovers from rather\n", + "than a crash." ] }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 6, + "id": "67e0bfae", "metadata": {}, "outputs": [], "source": [ @@ -358,14 +345,25 @@ "\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", + " \"\"\"Seam for shaping the prompt before each call -- add file trees, summaries, etc.\n", + "\n", + " This runs before every model call, not just the first, so anything added\n", + " here is applied on each turn of the loop.\n", + " \"\"\"\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", + " writes=[\n", + " \"messages\",\n", + " \"next_tool\",\n", + " \"next_args\",\n", + " \"last_tool_call_id\",\n", + " \"steps\",\n", + " \"done\",\n", + " \"final_answer\",\n", + " ],\n", ")\n", "def call_llm(state: State, client: Callable) -> State:\n", " \"\"\"Asks the model what to do next: call a tool, or finish.\"\"\"\n", @@ -392,12 +390,40 @@ " {\n", " \"id\": call[\"id\"],\n", " \"type\": \"function\",\n", - " \"function\": {\"name\": call[\"name\"], \"arguments\": json.dumps(call[\"args\"])},\n", + " \"function\": {\n", + " \"name\": call[\"name\"],\n", + " \"arguments\": json.dumps(call[\"args\"]),\n", + " },\n", " }\n", " ],\n", " }\n", + " messages = state[\"messages\"] + [assistant_message]\n", + "\n", + " # A model can ask for a tool that doesn't exist. Tell it so, rather than\n", + " # leaving the state machine with no matching transition.\n", + " if call[\"name\"] not in TOOLS:\n", + " known = \", \".join(sorted(TOOLS))\n", + " messages = messages + [\n", + " {\n", + " \"role\": \"tool\",\n", + " \"tool_call_id\": call[\"id\"],\n", + " \"content\": json.dumps(\n", + " {\"error\": f\"unknown tool {call['name']!r}. Available tools: {known}.\"}\n", + " ),\n", + " }\n", + " ]\n", + " return state.update(\n", + " messages=messages,\n", + " next_tool=None,\n", + " next_args={},\n", + " last_tool_call_id=call[\"id\"],\n", + " steps=state[\"steps\"] + 1,\n", + " done=False,\n", + " final_answer=None,\n", + " )\n", + "\n", " return state.update(\n", - " messages=state[\"messages\"] + [assistant_message],\n", + " messages=messages,\n", " next_tool=call[\"name\"],\n", " next_args=call[\"args\"],\n", " last_tool_call_id=call[\"id\"],\n", @@ -409,8 +435,17 @@ "\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", + " \"\"\"Runs one tool and feeds its result back to the model.\n", + "\n", + " Tool failures are returned to the model rather than raised, so a bad\n", + " argument name or a missing file is something the agent can recover from.\n", + " \"\"\"\n", + " try:\n", + " result = tool_function(**state[\"next_args\"])\n", + " except TypeError as e:\n", + " result = {\"error\": f\"bad arguments for tool: {e}\"}\n", + " except Exception as e:\n", + " result = {\"error\": f\"{type(e).__name__}: {e}\"}\n", " return state.update(\n", " messages=state[\"messages\"]\n", " + [\n", @@ -435,22 +470,25 @@ }, { "cell_type": "markdown", + "id": "a8c68749", "metadata": {}, "source": [ - "# Wiring the graph\n", + "## 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." + "Tool results route back through `create_prompt`, not straight to `call_llm`, so\n", + "the prompt seam runs before every model call. Each tool is a separately bound\n", + "action, so the Burr UI shows exactly which one ran at each step." ] }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 7, + "id": "c6d5ab05", "metadata": {}, "outputs": [], "source": [ @@ -472,13 +510,21 @@ " .with_transitions(\n", " (\"human_input\", \"create_prompt\"),\n", " (\"create_prompt\", \"call_llm\"),\n", + " # Exits come first: Burr takes the first condition that matches.\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", + " # Unknown tool: the error is already in the messages, so loop back\n", + " # and let the model choose again.\n", + " (\"call_llm\", \"create_prompt\", when(next_tool=None)),\n", + " # Safety net -- the machine should never be left with nowhere to go.\n", + " (\"call_llm\", \"respond\", default),\n", + " # Tool results go back through create_prompt, so the seam runs\n", + " # before every model call rather than only the first.\n", + " ([\"read_file\", \"write_file\", \"list_files\", \"run_bash\"], \"create_prompt\"),\n", " (\"respond\", \"human_input\"),\n", " )\n", " .with_state(max_steps=max_steps, steps=0, messages=[], done=False, final_answer=None)\n", @@ -491,14 +537,15 @@ }, { "cell_type": "markdown", + "id": "0e5f1ce3", "metadata": {}, "source": [ - "# Running it" + "## Running it" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 8, "metadata": {}, "outputs": [ { @@ -511,156 +558,168 @@ " -->\n", "\n", "\n", + " viewBox=\"0.00 0.00 490.50 309.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n", "\n", "%3\n", - "\n", + "\n", "\n", "\n", "human_input\n", - "\n", - "human_input\n", + "\n", + "human_input\n", "\n", "\n", "\n", "create_prompt\n", - "\n", - "create_prompt\n", + "\n", + "create_prompt\n", "\n", "\n", "\n", "human_input->create_prompt\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "input__task\n", - "\n", - "input: task\n", + "\n", + "input: task\n", "\n", "\n", "\n", "input__task->human_input\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "call_llm\n", - "\n", - "call_llm\n", + "\n", + "call_llm\n", "\n", "\n", "\n", "create_prompt->call_llm\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "respond\n", - "\n", - "respond\n", + "\n", + "respond\n", "\n", "\n", - "\n", + "\n", "respond->human_input\n", - "\n", - "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "call_llm->create_prompt\n", + "\n", + "\n", "\n", "\n", "\n", "call_llm->respond\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "call_llm->respond\n", - "\n", - "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "call_llm->respond\n", + "\n", + "\n", "\n", "\n", "\n", "read_file\n", - "\n", - "read_file\n", + "\n", + "read_file\n", "\n", "\n", "\n", "call_llm->read_file\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "write_file\n", - "\n", - "write_file\n", + "\n", + "write_file\n", "\n", "\n", "\n", "call_llm->write_file\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "list_files\n", - "\n", - "list_files\n", + "\n", + "list_files\n", "\n", "\n", "\n", "call_llm->list_files\n", - "\n", - "\n", + "\n", + "\n", "\n", "\n", "\n", "run_bash\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", - "\n", + "\n", "\n", - "list_files->call_llm\n", - "\n", - "\n", + "read_file->create_prompt\n", + "\n", + "\n", "\n", - "\n", + "\n", "\n", - "run_bash->call_llm\n", - "\n", - "\n", + "write_file->create_prompt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "list_files->create_prompt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "run_bash->create_prompt\n", + "\n", + "\n", "\n", "\n", "\n" ], "text/plain": [ - "" + "" ] }, - "execution_count": 16, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -672,7 +731,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -694,7 +753,7 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv (3.12.1.final.0)", + "display_name": ".venv (3.14.2)", "language": "python", "name": "python3" }, @@ -708,7 +767,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.1" + "version": "3.14.2" } }, "nbformat": 4, diff --git a/examples/coding-agent/test_coding_agent.py b/examples/coding-agent/test_coding_agent.py new file mode 100644 index 000000000..36489aa31 --- /dev/null +++ b/examples/coding-agent/test_coding_agent.py @@ -0,0 +1,173 @@ +# 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 harness. + +Everything here runs against ScriptedClient, so no API key is needed and the +suite is safe to run in CI. +""" + +import json +import os + +import pytest +import tools +from application import ScriptedClient, application + + +@pytest.fixture(autouse=True) +def workspace(tmp_path, monkeypatch): + """Point the tools at a throwaway directory for each test.""" + monkeypatch.setattr(tools, "WORKSPACE", str(tmp_path)) + return tmp_path + + +def run(script, task="do the thing", **kwargs): + """Run the agent against a fixed script and return the final state.""" + app = application(client=ScriptedClient(script), **kwargs) + _, _, state = app.run(halt_after=["respond"], inputs={"task": task}) + return state + + +def tool_messages(state): + return [m for m in state["messages"] if m["role"] == "tool"] + + +# --- the happy path -------------------------------------------------------- + + +def test_scripted_run_completes(workspace): + state = run( + [ + { + "content": None, + "tool_calls": [ + { + "id": "c1", + "name": "write_file", + "args": {"path": "hello.py", "contents": "print('hi')\n"}, + } + ], + }, + {"content": "Wrote hello.py.", "tool_calls": []}, + ] + ) + assert state["final_answer"] == "Wrote hello.py." + assert state["done"] is True + assert (workspace / "hello.py").read_text() == "print('hi')\n" + + +def test_create_prompt_runs_before_every_call(): + """The prompt seam must run on each turn, not only the first.""" + seen = [] + app = application( + client=ScriptedClient( + [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "list_files", "args": {}}], + }, + {"content": "done", "tool_calls": []}, + ] + ) + ) + for action, _, _ in app.iterate(halt_after=["respond"], inputs={"task": "t"}): + seen.append(action.name) + assert seen.count("create_prompt") == 2, seen + + +# --- termination ----------------------------------------------------------- + + +def test_step_budget_terminates_the_loop(): + """An agent that never finishes stops at max_steps.""" + forever = [ + {"content": None, "tool_calls": [{"id": f"c{i}", "name": "list_files", "args": {}}]} + for i in range(20) + ] + state = run(forever, max_steps=4) + assert state["steps"] >= 4 + assert "Stopped after" in state["final_answer"] + + +# --- recovery -------------------------------------------------------------- + + +def test_unknown_tool_is_reported_back_to_the_model(): + state = run( + [ + {"content": None, "tool_calls": [{"id": "c1", "name": "nope", "args": {}}]}, + {"content": "Picked a real tool.", "tool_calls": []}, + ] + ) + assert state["final_answer"] == "Picked a real tool." + errors = [json.loads(m["content"]) for m in tool_messages(state)] + assert any("unknown tool" in e.get("error", "") for e in errors) + + +def test_bad_argument_names_do_not_crash_the_run(): + state = run( + [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "read_file", "args": {"filename": "x.txt"}}], + }, + {"content": "Recovered.", "tool_calls": []}, + ] + ) + assert state["final_answer"] == "Recovered." + errors = [json.loads(m["content"]) for m in tool_messages(state)] + assert any("bad arguments" in e.get("error", "") for e in errors) + + +def test_tool_failure_is_returned_not_raised(): + state = run( + [ + { + "content": None, + "tool_calls": [{"id": "c1", "name": "read_file", "args": {"path": "missing.txt"}}], + }, + {"content": "Handled the missing file.", "tool_calls": []}, + ] + ) + assert state["final_answer"] == "Handled the missing file." + errors = [json.loads(m["content"]) for m in tool_messages(state)] + assert any("error" in e for e in errors) + + +# --- workspace confinement ------------------------------------------------- + + +def test_read_outside_the_workspace_is_refused(): + assert "error" in tools.read_file("../../../etc/passwd") + + +def test_write_outside_the_workspace_is_refused(): + assert "error" in tools.write_file("../escaped.txt", "nope") + + +def test_symlink_out_of_the_workspace_is_refused(workspace, tmp_path): + secret = tmp_path.parent / "secret.txt" + secret.write_text("classified") + os.symlink(secret, workspace / "link.txt") + assert "error" in tools.read_file("link.txt") + + +def test_run_bash_works_before_anything_is_written(workspace): + """The workspace must exist even if no tool has written to it yet.""" + result = tools.run_bash("pwd") + assert result["exit_code"] == 0 diff --git a/examples/coding-agent/tools.py b/examples/coding-agent/tools.py index 81c6d45eb..8d824988e 100644 --- a/examples/coding-agent/tools.py +++ b/examples/coding-agent/tools.py @@ -1,4 +1,3 @@ -"""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 @@ -16,16 +15,7 @@ # 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 +"""Tools the coding agent can call. Each takes typed args and returns a dict.""" import os import subprocess @@ -33,11 +23,15 @@ # Everything is confined to this directory. See README on why this is not a sandbox. WORKSPACE = os.environ.get("CODING_AGENT_WORKSPACE", "./workspace") +# The agent may run a command before it has written anything, so make sure the +# directory exists rather than letting subprocess fail on a missing cwd. +os.makedirs(WORKSPACE, exist_ok=True) + 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)) + 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 @@ -77,8 +71,12 @@ 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, + command, + shell=True, + cwd=os.path.abspath(WORKSPACE), + capture_output=True, + text=True, + timeout=30, ) return { "exit_code": proc.returncode, @@ -86,4 +84,4 @@ 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"} From 2b30eb27f59d99188e8c1b9e10be18833b4896e0 Mon Sep 17 00:00:00 2001 From: NJEI PIERRICK Jnr Date: Wed, 21 Oct 2026 01:48:59 +0000 Subject: [PATCH 4/4] fix(examples): exclude coding-agent from the sdist and fix requirements.txt EOF --- examples/coding-agent/requirements.txt | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/pyproject.toml b/pyproject.toml index 8ce1bf8a5..b16bcd903 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -305,6 +305,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/**",