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/2] 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/2] 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