agent_tools.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. """A small agent against a sandboxed file tree: can the model drive real tools?
  2. Each task starts from a fresh in-memory tree, offers list_dir, read_file,
  3. search_files, write_file and finish, runs the model's calls itself, and checks
  4. the tree (or the finish answer) at the end. Every task runs streamed and not
  5. streamed, because the two take different parser paths.
  6. python3 agent_tools.py http://HEAD:8002 [--model glm53] [--only NAME] [-v]
  7. Prints one line per task and mode, then totals: tasks passed, turns, calls,
  8. calls refused by the server (the fail-closed parser's sentinel), calls to tools
  9. not offered, and calls whose arguments did not parse.
  10. """
  11. import argparse
  12. import copy
  13. import json
  14. import re
  15. import sys
  16. import urllib.request
  17. REFUSED = "_rejected_by_server"
  18. MAX_TURNS = 12
  19. TOOLS = [
  20. {"type": "function", "function": {"name": "list_dir", "description": "List a directory's entries.",
  21. "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
  22. {"type": "function", "function": {"name": "read_file", "description": "Read a whole file.",
  23. "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}},
  24. {"type": "function", "function": {"name": "search_files", "description":
  25. "Search file contents under a directory with a regular expression; returns path:line:text matches.",
  26. "parameters": {"type": "object", "properties": {"pattern": {"type": "string"}, "path": {"type": "string"}},
  27. "required": ["pattern"]}}},
  28. {"type": "function", "function": {"name": "write_file", "description": "Create or replace a file.",
  29. "parameters": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}},
  30. "required": ["path", "content"]}}},
  31. {"type": "function", "function": {"name": "finish", "description": "End the task with a short answer.",
  32. "parameters": {"type": "object", "properties": {"answer": {"type": "string"}}, "required": ["answer"]}}},
  33. ]
  34. TREE = {
  35. "/app/README.md": "# Inventory service\nRun `make serve`. Settings live in config/.\n",
  36. "/app/config/base.yaml": "service: inventory\nlog_level: info\n",
  37. "/app/config/prod.yaml": "listen_port: 8443\nreplicas: 3\ndatabse_url: postgres://db:5432/inv\n",
  38. "/app/config/dev.yaml": "listen_port: 8080\nreplicas: 1\ndatabse_url: postgres://localhost/inv\n",
  39. "/app/src/server.py": "# TODO: graceful shutdown\nimport config\n\ndef serve():\n # TODO: TLS\n pass\n",
  40. "/app/src/store.py": "def get(item):\n return None # TODO: cache\n",
  41. "/app/src/util.py": "def slug(s):\n return s.lower().replace(' ', '-')\n",
  42. }
  43. def _norm(path: str) -> str:
  44. path = "/" + (path or "/").strip().lstrip("./")
  45. return re.sub(r"/+", "/", path).rstrip("/") or "/"
  46. class Sandbox:
  47. def __init__(self) -> None:
  48. self.files = copy.deepcopy(TREE)
  49. def list_dir(self, path: str) -> str:
  50. path = _norm(path)
  51. entries = set()
  52. for f in self.files:
  53. if f.startswith(path + "/") or path == "/":
  54. rest = f[len(path):].lstrip("/")
  55. entries.add(rest.split("/")[0] + ("/" if "/" in rest else ""))
  56. return "\n".join(sorted(entries)) or f"error: no such directory {path}"
  57. def read_file(self, path: str) -> str:
  58. return self.files.get(_norm(path), f"error: no such file {_norm(path)}")
  59. def search_files(self, pattern: str, path: str = "/") -> str:
  60. try:
  61. rx = re.compile(pattern)
  62. except re.error as e:
  63. return f"error: bad pattern: {e}"
  64. root = _norm(path)
  65. hits = [f"{f}:{i}:{line}" for f, text in sorted(self.files.items()) if f.startswith(root)
  66. for i, line in enumerate(text.splitlines(), 1) if rx.search(line)]
  67. return "\n".join(hits[:50]) or "no matches"
  68. def write_file(self, path: str, content: str) -> str:
  69. self.files[_norm(path)] = content
  70. return f"wrote {len(content)} bytes to {_norm(path)}"
  71. # (name, prompt, check(sandbox, finish_answer) -> bool)
  72. TASKS = [
  73. ("find-port", "Which port does the production config listen on? Write just the number to /app/answer.txt, "
  74. "then finish.", lambda s, a: s.files.get("/app/answer.txt", "").strip() == "8443"),
  75. ("fix-typo", "The key `databse_url` is misspelled in every config file under /app/config. Fix it to "
  76. "`database_url` without changing anything else, then finish.",
  77. lambda s, a: all(s.files[f] == TREE[f].replace("databse_url", "database_url")
  78. for f in TREE if f.startswith("/app/config/"))),
  79. ("count-todos", "How many TODO comments are there in /app/src? Finish with the number.",
  80. lambda s, a: re.search(r"\b3\b", a or "") is not None),
  81. ("new-file", "Create /app/config/staging.yaml copying prod.yaml but with listen_port 9090 and 2 replicas. "
  82. "Then finish.", lambda s, a: "listen_port: 9090" in s.files.get("/app/config/staging.yaml", "")
  83. and "replicas: 2" in s.files.get("/app/config/staging.yaml", "")
  84. and "postgres://db:5432/inv" in s.files.get("/app/config/staging.yaml", "")),
  85. ("read-then-answer", "What does slug() in /app/src/util.py do to spaces? Finish with a one-line answer.",
  86. lambda s, a: "-" in (a or "") or "hyphen" in (a or "").lower() or "dash" in (a or "").lower()),
  87. ]
  88. def chat(url, model, messages, stream):
  89. body = {"model": model, "messages": messages, "tools": TOOLS, "temperature": 0, "max_tokens": 2048,
  90. "stream": stream, "chat_template_kwargs": {"reasoning_effort": "low"}}
  91. req = urllib.request.Request(url + "/v1/chat/completions", json.dumps(body).encode(),
  92. {"Content-Type": "application/json"})
  93. with urllib.request.urlopen(req, timeout=600) as r:
  94. if not stream:
  95. return json.load(r)["choices"][0]["message"]
  96. content, calls = "", {}
  97. for raw in r:
  98. line = raw.decode().strip()
  99. if not line.startswith("data:") or line == "data: [DONE]":
  100. continue
  101. delta = json.loads(line[5:])["choices"][0].get("delta", {})
  102. content += delta.get("content") or ""
  103. for tc in delta.get("tool_calls") or []:
  104. c = calls.setdefault(tc["index"], {"id": "", "type": "function",
  105. "function": {"name": "", "arguments": ""}})
  106. c["id"] = tc.get("id") or c["id"]
  107. fn = tc.get("function") or {}
  108. c["function"]["name"] += fn.get("name") or ""
  109. c["function"]["arguments"] += fn.get("arguments") or ""
  110. return {"role": "assistant", "content": content or None,
  111. "tool_calls": [calls[i] for i in sorted(calls)] or None}
  112. def run_task(url, model, task, stream, verbose):
  113. name, prompt, check = task
  114. box, answer = Sandbox(), None
  115. stats = {"turns": 0, "calls": 0, "refused": 0, "unknown": 0, "bad_args": 0}
  116. messages = [{"role": "system", "content": "You operate on a small repository through the tools given. "
  117. "Use them; do not guess file contents."},
  118. {"role": "user", "content": prompt}]
  119. for _ in range(MAX_TURNS):
  120. stats["turns"] += 1
  121. msg = chat(url, model, messages, stream)
  122. calls = msg.get("tool_calls") or []
  123. messages.append({k: v for k, v in msg.items() if k in ("role", "content", "tool_calls") and v})
  124. if not calls:
  125. # A plain reply ends the task; it counts as the answer.
  126. answer = answer if answer is not None else (msg.get("content") or "")
  127. break
  128. for call in calls:
  129. stats["calls"] += 1
  130. fn = call["function"]
  131. try:
  132. args = json.loads(fn["arguments"] or "{}")
  133. except json.JSONDecodeError:
  134. stats["bad_args"] += 1
  135. result = "error: arguments are not valid JSON"
  136. else:
  137. if REFUSED in args:
  138. stats["refused"] += 1
  139. result = f"error: {args[REFUSED]}"
  140. elif fn["name"] == "finish":
  141. answer = str(args.get("answer", ""))
  142. result = "done"
  143. elif hasattr(Sandbox, fn["name"]) and not fn["name"].startswith("_"):
  144. try:
  145. result = getattr(box, fn["name"])(**args)
  146. except TypeError as e:
  147. stats["bad_args"] += 1
  148. result = f"error: {e}"
  149. else:
  150. stats["unknown"] += 1
  151. result = f"error: no tool named {fn['name']}"
  152. if verbose:
  153. print(f" {fn['name']}({fn['arguments'][:80]}) -> {str(result)[:80]!r}")
  154. messages.append({"role": "tool", "tool_call_id": call.get("id") or "", "content": str(result)})
  155. if answer is not None:
  156. break
  157. return check(box, answer), stats
  158. def main():
  159. ap = argparse.ArgumentParser()
  160. ap.add_argument("url")
  161. ap.add_argument("--model", default="glm53")
  162. ap.add_argument("--only")
  163. ap.add_argument("-v", "--verbose", action="store_true")
  164. a = ap.parse_args()
  165. totals = {"passed": 0, "runs": 0, "turns": 0, "calls": 0, "refused": 0, "unknown": 0, "bad_args": 0}
  166. for task in TASKS:
  167. if a.only and task[0] != a.only:
  168. continue
  169. for stream in (False, True):
  170. ok, s = run_task(a.url.rstrip("/"), a.model, task, stream, a.verbose)
  171. totals["passed"] += ok
  172. totals["runs"] += 1
  173. for k in s:
  174. totals[k] += s[k]
  175. mode = "streamed" if stream else "whole"
  176. print(f" {task[0]:<17} {mode:<9} {'pass' if ok else 'FAIL'} turns {s['turns']:>2} calls {s['calls']:>2}"
  177. f" refused {s['refused']} unknown {s['unknown']} bad args {s['bad_args']}", flush=True)
  178. print(f"agent tools: {totals['passed']}/{totals['runs']} passed, {totals['calls']} calls in {totals['turns']} turns,"
  179. f" {totals['refused']} refused, {totals['unknown']} unknown tool, {totals['bad_args']} bad args")
  180. sys.exit(0 if totals["passed"] == totals["runs"] else 1)
  181. if __name__ == "__main__":
  182. main()