mulebot 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {mulebot-0.1.2 → mulebot-0.2.0}/PKG-INFO +1 -1
  2. mulebot-0.2.0/mulebot/__init__.py +4 -0
  3. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/agent.py +76 -21
  4. mulebot-0.2.0/mulebot/tool.py +139 -0
  5. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/tools.py +24 -21
  6. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/PKG-INFO +1 -1
  7. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/SOURCES.txt +2 -2
  8. {mulebot-0.1.2 → mulebot-0.2.0}/setup.py +1 -1
  9. mulebot-0.1.2/mulebot/__init__.py +0 -3
  10. mulebot-0.1.2/tests/test_plot.py +0 -133
  11. {mulebot-0.1.2 → mulebot-0.2.0}/LICENSE +0 -0
  12. {mulebot-0.1.2 → mulebot-0.2.0}/MANIFEST.in +0 -0
  13. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/__main__.py +0 -0
  14. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/assets.py +0 -0
  15. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/cli.py +0 -0
  16. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/datasets.md +0 -0
  17. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/docs.md +0 -0
  18. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/index.yaml +0 -0
  19. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/products.md +0 -0
  20. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/readme.md +0 -0
  21. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/schema.md +0 -0
  22. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec-filings-lookup.md +0 -0
  23. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec_filings_archive.md +0 -0
  24. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec_filings_notifications.md +0 -0
  25. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/transfer_to_your_cloud.md +0 -0
  26. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/utils.md +0 -0
  27. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/server.py +0 -0
  28. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/static/app.js +0 -0
  29. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/static/style.css +0 -0
  30. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/templates/index.html +0 -0
  31. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/dependency_links.txt +0 -0
  32. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/entry_points.txt +0 -0
  33. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/requires.txt +0 -0
  34. {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/top_level.txt +0 -0
  35. {mulebot-0.1.2 → mulebot-0.2.0}/pyproject.toml +0 -0
  36. {mulebot-0.1.2 → mulebot-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mulebot
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: A simple chatbot for Datamule's APIs
5
5
  Home-page: https://github.com/john-friedman/mulebot
6
6
  Author: John Friedman
@@ -0,0 +1,4 @@
1
+ from .agent import Agent
2
+ from .tool import Tool, tool
3
+
4
+ __all__ = ["Agent", "Tool", "tool"]
@@ -5,7 +5,8 @@ import urllib.request
5
5
  from datetime import date
6
6
  from time import perf_counter, time
7
7
 
8
- from .tools import TOOL_DEFINITIONS, Tools, documentation_context
8
+ from .tools import Tools, documentation_context
9
+ from .tool import validate_tools
9
10
 
10
11
 
11
12
  DEFAULT_MODEL = "deepseek/deepseek-v4.1-flash-1017:nitro"
@@ -68,15 +69,67 @@ class Agent:
68
69
  api_key = api_key or os.environ.get("DATAMULE_API_KEY")
69
70
  if not api_key or not self.model_api_key:
70
71
  raise ValueError("Set DATAMULE_API_KEY and OPENROUTER_API_KEY.")
71
- self.tools = Tools(api_key, allowed_tables=allowed_tables)
72
- context = f"Today is {date.today().isoformat()}.\n\n<documentation>\n"
73
- context += documentation_context() + "\n</documentation>\n\n" + INSTRUCTIONS
74
- self.messages = [{"role": "system", "content": context}]
72
+ self._tool_state = Tools(api_key, allowed_tables=allowed_tables)
73
+ self.instructions = INSTRUCTIONS
74
+ self.doc = documentation_context()
75
+ self.messages = []
76
+ self._update_system_message()
77
+
78
+ @property
79
+ def tools(self):
80
+ """All active tools, including built-ins. Use set_tools/append_tools to change them."""
81
+ return validate_tools(self._tool_state.registry)
82
+
83
+ @property
84
+ def results(self):
85
+ return self._tool_state.results
86
+
87
+ @property
88
+ def artifacts(self):
89
+ return self._tool_state.artifacts
90
+
91
+ def set_instructions(self, text):
92
+ if not isinstance(text, str):
93
+ raise TypeError("Instructions must be a string.")
94
+ self.instructions = text
95
+ self._update_system_message()
96
+ return self
97
+
98
+ def append_instructions(self, text):
99
+ if not isinstance(text, str):
100
+ raise TypeError("Instructions must be a string.")
101
+ return self.set_instructions("\n\n".join(part for part in (self.instructions, text) if part))
102
+
103
+ def set_doc(self, text):
104
+ if not isinstance(text, str):
105
+ raise TypeError("Documentation must be a string.")
106
+ self.doc = text
107
+ self._update_system_message()
108
+ return self
109
+
110
+ def append_doc(self, text):
111
+ if not isinstance(text, str):
112
+ raise TypeError("Documentation must be a string.")
113
+ return self.set_doc("\n\n".join(part for part in (self.doc, text) if part))
114
+
115
+ def set_tools(self, tools):
116
+ self._tool_state.registry = validate_tools(tools)
117
+ return self
118
+
119
+ def append_tools(self, tools):
120
+ if not isinstance(tools, (list, tuple)):
121
+ raise TypeError("Tools must be a list or tuple.")
122
+ return self.set_tools([*self.tools, *tools])
123
+
124
+ def _update_system_message(self):
125
+ content = (f"Today is {date.today().isoformat()}.\n\n<documentation>\n"
126
+ + self.doc + "\n</documentation>\n\n" + self.instructions)
127
+ self.messages[:1] = [{"role": "system", "content": content}]
75
128
 
76
129
  def export_state(self):
77
130
  """Return browser-held chat data, without credentials or system instructions."""
78
- state = {"messages": self.messages[1:], "results": self.tools.results,
79
- "artifacts": self.tools.artifacts}
131
+ state = {"messages": self.messages[1:], "results": self._tool_state.results,
132
+ "artifacts": self._tool_state.artifacts}
80
133
  return json.loads(self._redact(json.dumps(state, ensure_ascii=False)))
81
134
 
82
135
  def load_state(self, state):
@@ -129,29 +182,30 @@ class Agent:
129
182
  if not all(isinstance(artifact, dict) for artifact in artifacts):
130
183
  raise ValueError("Invalid saved artifacts.")
131
184
  self.messages = self.messages[:1] + restored
132
- self.tools.results = results
133
- self.tools.artifacts = artifacts
185
+ self._tool_state.results = results
186
+ self._tool_state.artifacts = artifacts
134
187
 
135
188
  def _redact(self, text):
136
- for key in [self.model_api_key, self.tools.api_key]:
189
+ for key in [self.model_api_key, self._tool_state.api_key]:
137
190
  text = text.replace(key, "[redacted]")
138
191
  return text
139
192
 
140
193
  def _complete(self, tool_choice):
141
194
  messages = list(self.messages)
142
- if self.tools.results:
195
+ if self._tool_state.results:
143
196
  saved = {key: {"columns": value["columns"], "row_count": len(value["rows"]), "sql": value["sql"]}
144
- for key, value in self.tools.results.items()}
197
+ for key, value in self._tool_state.results.items()}
145
198
  messages.append({"role": "system", "content":
146
199
  "These results are already saved and remain available even if older chat was trimmed. "
147
200
  "Use query(result_id=...) or plot.sql to read them locally instead of paying to fetch "
148
201
  "the same data again. Local SQL reads the table data using SQLite.\n" + json.dumps(saved)})
149
202
  payload = {
150
203
  "model": self.model, "messages": messages,
151
- "tools": TOOL_DEFINITIONS, "tool_choice": tool_choice,
152
- "max_tokens": 8192, "parallel_tool_calls": False,
204
+ "max_tokens": 8192,
153
205
  "reasoning": {"effort": os.environ.get("MULEBOT_REASONING_EFFORT", "medium"), "exclude": False},
154
206
  }
207
+ if self._tool_state.registry:
208
+ payload.update(tools=self._tool_state.definitions, tool_choice=tool_choice, parallel_tool_calls=False)
155
209
  request = urllib.request.Request(
156
210
  "https://openrouter.ai/api/v1/chat/completions",
157
211
  data=json.dumps(payload).encode("utf-8"),
@@ -233,8 +287,8 @@ class Agent:
233
287
  "llm_cost": (completion.get("usage") or {}).get("cost"),
234
288
  "llm_cost_shared": len(calls) > 1,
235
289
  "reasoning": self._redact(reasoning_text(response))}
236
- result_ids = set(self.tools.results)
237
- first_artifact = len(self.tools.artifacts)
290
+ result_ids = set(self._tool_state.results)
291
+ first_artifact = len(self._tool_state.artifacts)
238
292
  started = perf_counter()
239
293
  try:
240
294
  if remaining <= 0:
@@ -243,7 +297,8 @@ class Agent:
243
297
  arguments = json.loads(call["function"]["arguments"])
244
298
  if not isinstance(arguments, dict):
245
299
  raise ValueError("Tool arguments must be an object.")
246
- result = self.tools.call(name, arguments)
300
+ result = self._tool_state.call(name, arguments)
301
+ result = json.loads(json.dumps(result, ensure_ascii=False, allow_nan=False))
247
302
  except Exception as exc:
248
303
  result = {"error": self._redact(str(exc))[:2000]}
249
304
  if getattr(exc, "metadata", None):
@@ -253,10 +308,10 @@ class Agent:
253
308
  "content": json.dumps(result, ensure_ascii=False)})
254
309
  yield {"type": "tool_result", "id": call["id"], "name": name,
255
310
  "result": result, "duration_ms": duration_ms}
256
- for result_id in self.tools.results.keys() - result_ids:
257
- yield {"type": "query_result", "result_id": result_id, **self.tools.results[result_id]}
258
- for i in range(first_artifact, len(self.tools.artifacts)):
259
- yield {"type": "artifact", "artifact_id": i + 1, "artifact": self.tools.artifacts[i]}
311
+ for result_id in self._tool_state.results.keys() - result_ids:
312
+ yield {"type": "query_result", "result_id": result_id, **self._tool_state.results[result_id]}
313
+ for i in range(first_artifact, len(self._tool_state.artifacts)):
314
+ yield {"type": "artifact", "artifact_id": i + 1, "artifact": self._tool_state.artifacts[i]}
260
315
  except Exception as exc:
261
316
  detail = self._redact(str(exc))[:2000]
262
317
  interruption = f"Turn interrupted: {detail}"
@@ -0,0 +1,139 @@
1
+ import inspect
2
+ import math
3
+ import re
4
+ import types
5
+ from copy import deepcopy
6
+ from dataclasses import dataclass, replace
7
+ from typing import Annotated, Any, Literal, Union, get_args, get_origin, get_type_hints
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class Tool:
12
+ name: str
13
+ description: str
14
+ parameters: dict
15
+ execute: Any
16
+
17
+ @property
18
+ def definition(self):
19
+ return {"type": "function", "function": {
20
+ "name": self.name, "description": self.description,
21
+ "parameters": deepcopy(self.parameters),
22
+ }}
23
+
24
+ def __call__(self, *args, **kwargs):
25
+ return self.execute(*args, **kwargs)
26
+
27
+
28
+ def _schema(annotation):
29
+ if annotation is Any:
30
+ return {}
31
+ if annotation is None or annotation is type(None):
32
+ return {"type": "null"}
33
+ primitives = {str: "string", int: "integer", float: "number", bool: "boolean"}
34
+ if annotation in (str, int, float, bool):
35
+ return {"type": primitives[annotation]}
36
+ origin, args = get_origin(annotation), get_args(annotation)
37
+ if origin is Annotated:
38
+ return _schema(args[0])
39
+ if origin in (Union, types.UnionType):
40
+ return {"anyOf": [_schema(arg) for arg in args]}
41
+ if origin is Literal:
42
+ return {"enum": list(args)}
43
+ if annotation is list or origin is list:
44
+ return {"type": "array", "items": _schema(args[0]) if args else {}}
45
+ if annotation is dict or origin is dict:
46
+ if args and args[0] is not str:
47
+ raise TypeError("Tool dictionary keys must be strings.")
48
+ return {"type": "object", "additionalProperties": _schema(args[1]) if args else True}
49
+ raise TypeError(f"Unsupported tool annotation: {annotation!r}. Supply an explicit parameters schema.")
50
+
51
+
52
+ def tool(function=None, *, name=None, description=None, parameters=None):
53
+ """Declare a synchronous tool using type hints, or an explicit JSON Schema."""
54
+ def decorate(function):
55
+ if inspect.iscoroutinefunction(function):
56
+ raise TypeError("Python tools must be synchronous functions.")
57
+ schema = parameters
58
+ if schema is None:
59
+ hints = get_type_hints(function, include_extras=True)
60
+ properties, required = {}, []
61
+ for key, parameter in inspect.signature(function).parameters.items():
62
+ if parameter.kind not in (parameter.POSITIONAL_OR_KEYWORD, parameter.KEYWORD_ONLY):
63
+ raise TypeError("Tool parameters must accept keyword arguments.")
64
+ if key not in hints:
65
+ raise TypeError(f"Tool parameter {key} needs a type annotation.")
66
+ properties[key] = _schema(hints[key])
67
+ if parameter.default is parameter.empty:
68
+ required.append(key)
69
+ schema = {"type": "object", "properties": properties,
70
+ "required": required, "additionalProperties": False}
71
+ return validate_tools([Tool(name or function.__name__,
72
+ description if description is not None else inspect.getdoc(function) or "",
73
+ schema, function)])[0]
74
+ return decorate if function is None else decorate(function)
75
+
76
+
77
+ def validate_tools(tools):
78
+ if not isinstance(tools, (list, tuple)):
79
+ raise TypeError("Tools must be a list or tuple.")
80
+ names, result = set(), []
81
+ for entry in tools:
82
+ if (not isinstance(entry, Tool) or not isinstance(entry.name, str)
83
+ or not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", entry.name)
84
+ or not isinstance(entry.description, str) or not isinstance(entry.parameters, dict)
85
+ or entry.parameters.get("type") != "object"
86
+ or not isinstance(entry.parameters.get("properties"), dict) or not callable(entry.execute)):
87
+ raise ValueError("Each tool needs a name, description, object parameter schema, and execute function.")
88
+ if inspect.iscoroutinefunction(entry.execute):
89
+ raise TypeError("Python tools must be synchronous functions.")
90
+ if entry.name in names:
91
+ raise ValueError(f"Duplicate tool name: {entry.name}")
92
+ names.add(entry.name)
93
+ result.append(replace(entry, parameters=deepcopy(entry.parameters)))
94
+ return result
95
+
96
+
97
+ def validate_arguments(value, schema, path):
98
+ if "anyOf" in schema:
99
+ for option in schema["anyOf"]:
100
+ try:
101
+ validate_arguments(value, option, path)
102
+ return
103
+ except ValueError:
104
+ pass
105
+ raise ValueError(f"{path} does not match any allowed type.")
106
+ checks = {
107
+ "string": lambda: isinstance(value, str),
108
+ "integer": lambda: type(value) is int,
109
+ "number": lambda: type(value) is int or (type(value) is float and math.isfinite(value)),
110
+ "boolean": lambda: isinstance(value, bool),
111
+ "null": lambda: value is None,
112
+ "object": lambda: isinstance(value, dict),
113
+ "array": lambda: isinstance(value, list),
114
+ }
115
+ kind = schema.get("type")
116
+ if kind in checks and not checks[kind]():
117
+ raise ValueError(f"{path} must be a {kind}.")
118
+ if "enum" in schema and not any(type(value) is type(entry) and value == entry for entry in schema["enum"]):
119
+ raise ValueError(f"{path} must be one of: {schema['enum']}.")
120
+ if isinstance(value, dict):
121
+ properties = schema.get("properties", {})
122
+ missing = [key for key in schema.get("required", []) if key not in value]
123
+ if missing:
124
+ raise ValueError(f"{path} is missing required field(s): {', '.join(missing)}.")
125
+ unexpected = [key for key in value if key not in properties]
126
+ additional = schema.get("additionalProperties", True)
127
+ if additional is False and unexpected:
128
+ raise ValueError(f"{path} has unknown field(s): {', '.join(unexpected)}. "
129
+ f"Allowed fields: {', '.join(properties)}.")
130
+ for key, entry in value.items():
131
+ child = properties.get(key, additional if isinstance(additional, dict) else None)
132
+ if child is not None:
133
+ validate_arguments(entry, child, f"{path}.{key}")
134
+ if isinstance(value, list):
135
+ if len(value) < schema.get("minItems", 0):
136
+ raise ValueError(f"{path} needs at least {schema['minItems']} item(s).")
137
+ if "items" in schema:
138
+ for index, entry in enumerate(value):
139
+ validate_arguments(entry, schema["items"], f"{path}[{index}]")
@@ -12,6 +12,7 @@ from sqlglot.optimizer.annotate_types import annotate_types
12
12
  from sqlglot.optimizer.scope import traverse_scope
13
13
 
14
14
  from .assets import resource
15
+ from .tool import Tool, validate_arguments, validate_tools
15
16
 
16
17
 
17
18
  SITE = "https://datamule.xyz"
@@ -73,7 +74,7 @@ def documentation_context():
73
74
  )
74
75
 
75
76
 
76
- def tool(name, description, properties, required=()):
77
+ def _tool_definition(name, description, properties, required=()):
77
78
  return {
78
79
  "type": "function",
79
80
  "function": {
@@ -89,7 +90,7 @@ def tool(name, description, properties, required=()):
89
90
 
90
91
  STRING = {"type": "string"}
91
92
  TOOL_DEFINITIONS = [
92
- tool("query", "Fetch the user's complete requested dataset with one read-only Athena SELECT. "
93
+ _tool_definition("query", "Fetch the user's complete requested dataset with one read-only Athena SELECT. "
93
94
  "Resolve identifiers in subqueries/CTEs within this SQL instead of separate lookup calls. "
94
95
  "Filter comparable records and deduplicate/aggregate before returning rows; do not fetch "
95
96
  "raw samples just to inspect them. Athena date differences use date_diff('day', start, end), "
@@ -100,9 +101,9 @@ TOOL_DEFINITIONS = [
100
101
  "sql": {"type": "string", "description": "Athena SQL for a new Datamule query, SQLite SQL for saved results. SQLite durations use julianday(end) - julianday(start), not CAST AS DATE or year subtraction."},
101
102
  "result_id": {"type": "string", "description": "Optional saved result to query locally using SQLite; omit only when fetching new data from Datamule."},
102
103
  }, ["sql"]),
103
- tool("write_markdown", "Create a text Markdown report for the caller to display or save. HTML and SVG are displayed as text, not rendered. Use plot for charts; fix a failed plot call instead of embedding SVG in Markdown.",
104
+ _tool_definition("write_markdown", "Create a text Markdown report for the caller to display or save. HTML and SVG are displayed as text, not rendered. Use plot for charts; fix a failed plot call instead of embedding SVG in Markdown.",
104
105
  {"title": STRING, "markdown": STRING}, ["title", "markdown"]),
105
- tool("plot", "Create one chart artifact from a required saved result_id returned by query. This tool never fetches new data. For separate charts, call plot separately with the same result_id and different local sql filters. Chart the final analysis rows used in your answer without querying Datamule again. y is a numeric column or a list of numeric columns for multiple lines; group is a categorical column, not another y. Optional sql filters or reshapes the saved rows using a read-only SQLite SELECT FROM data (SQLite dates use julianday; arrays/objects are JSON). Use this to reuse data on follow-ups. Line charts sort x ascending. Supply human-readable axis labels including units.", {
106
+ _tool_definition("plot", "Create one chart artifact from a required saved result_id returned by query. This tool never fetches new data. For separate charts, call plot separately with the same result_id and different local sql filters. Chart the final analysis rows used in your answer without querying Datamule again. y is a numeric column or a list of numeric columns for multiple lines; group is a categorical column, not another y. Optional sql filters or reshapes the saved rows using a read-only SQLite SELECT FROM data (SQLite dates use julianday; arrays/objects are JSON). Use this to reuse data on follow-ups. Line charts sort x ascending. Supply human-readable axis labels including units.", {
106
107
  "title": STRING, "result_id": {"type": "string", "description": "Required ID of a saved query result, such as query_5. Use its column names for x, y, and group. Fetch missing data with query first; never put a fresh Athena query into plot.sql."},
107
108
  "kind": {"type": "string", "enum": ["line", "bar", "scatter"]},
108
109
  "x": STRING, "y": {"anyOf": [STRING, {"type": "array", "items": STRING, "minItems": 1}]},
@@ -120,6 +121,11 @@ class Tools:
120
121
  self.catalog = None
121
122
  self.results = {}
122
123
  self.artifacts = []
124
+ self.registry = validate_tools([
125
+ Tool(definition["function"]["name"], definition["function"]["description"],
126
+ definition["function"]["parameters"], getattr(self, definition["function"]["name"]))
127
+ for definition in TOOL_DEFINITIONS
128
+ ])
123
129
 
124
130
  def _saved_results_help(self):
125
131
  saved = [f"{result_id} ({len(result['rows'])} rows; columns: {', '.join(result['columns'])})"
@@ -335,24 +341,21 @@ class Tools:
335
341
  response.update(columns=columns, rows=rows, row_count=len(rows))
336
342
  return response
337
343
 
344
+ @property
345
+ def definitions(self):
346
+ return [entry.definition for entry in self.registry]
347
+
338
348
  def call(self, name, arguments):
339
- definition = next((tool["function"] for tool in TOOL_DEFINITIONS
340
- if tool["function"]["name"] == name), None)
341
- if definition is None:
349
+ entry = next((entry for entry in self.registry if entry.name == name), None)
350
+ if entry is None:
342
351
  raise ValueError(f"Unknown tool: {name}")
343
352
  if not isinstance(arguments, dict):
344
353
  raise ValueError("Tool arguments must be an object.")
345
- properties = definition["parameters"]["properties"]
346
- missing = [key for key in definition["parameters"]["required"] if key not in arguments]
347
- if missing:
348
- hint = (" Every plot requires a saved result_id. Optional sql is SQLite SELECT FROM data, "
349
- f"not a new Athena query. {self._saved_results_help()}" if name == "plot" else "")
350
- raise ValueError(f"{name} is missing required field(s): {', '.join(missing)}.{hint}")
351
- unexpected = [key for key in arguments if key not in properties]
352
- if unexpected:
353
- raise ValueError(f"{name} has unknown field(s): {', '.join(unexpected)}. "
354
- f"Allowed fields: {', '.join(properties)}.")
355
- for key, value in arguments.items():
356
- if properties[key].get("type") == "string" and not isinstance(value, str):
357
- raise ValueError(f"{name}.{key} must be a string.")
358
- return getattr(self, name)(**arguments)
354
+ try:
355
+ validate_arguments(arguments, entry.parameters, name)
356
+ except ValueError as exc:
357
+ if name == "plot" and "missing required field(s)" in str(exc):
358
+ raise ValueError(f"{exc} Every plot requires a saved result_id. Optional sql is SQLite "
359
+ f"SELECT FROM data, not a new Athena query. {self._saved_results_help()}") from None
360
+ raise
361
+ return entry.execute(**arguments)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mulebot
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: A simple chatbot for Datamule's APIs
5
5
  Home-page: https://github.com/john-friedman/mulebot
6
6
  Author: John Friedman
@@ -8,11 +8,11 @@ mulebot/agent.py
8
8
  mulebot/assets.py
9
9
  mulebot/cli.py
10
10
  mulebot/server.py
11
+ mulebot/tool.py
11
12
  mulebot/tools.py
12
13
  mulebot.egg-info/PKG-INFO
13
14
  mulebot.egg-info/SOURCES.txt
14
15
  mulebot.egg-info/dependency_links.txt
15
16
  mulebot.egg-info/entry_points.txt
16
17
  mulebot.egg-info/requires.txt
17
- mulebot.egg-info/top_level.txt
18
- tests/test_plot.py
18
+ mulebot.egg-info/top_level.txt
@@ -31,7 +31,7 @@ class Sdist(sdist):
31
31
  setup(
32
32
  name="mulebot",
33
33
  author="John Friedman",
34
- version="0.1.2",
34
+ version="0.2.0",
35
35
  description="A simple chatbot for Datamule's APIs",
36
36
  url="https://github.com/john-friedman/mulebot",
37
37
  license="MIT",
@@ -1,3 +0,0 @@
1
- from .agent import Agent
2
-
3
- __all__ = ["Agent"]
@@ -1,133 +0,0 @@
1
- import json
2
- import unittest
3
- from pathlib import Path
4
- from unittest.mock import patch
5
-
6
- from mulebot.agent import Agent, INSTRUCTIONS
7
- from mulebot.tools import TOOL_DEFINITIONS, Tools
8
-
9
-
10
- PLOT = {"title": "RPO", "kind": "line", "x": "filingdate", "y": "rpo_b"}
11
- COMPANIES = ["Oracle", "Microsoft", "Alphabet"]
12
-
13
-
14
- def saved_tools():
15
- tools = Tools("test-key")
16
- tools.results["query_5"] = {
17
- "sql": "SELECT company, filingdate, rpo_b FROM simple_xbrl",
18
- "columns": ["company", "filingdate", "rpo_b"],
19
- "rows": [{"company": company, "filingdate": date, "rpo_b": amount + index}
20
- for index, company in enumerate(COMPANIES)
21
- for date, amount in [("2025-06-01", 100), ("2025-09-01", 400)]],
22
- "local_types": {"company": "TEXT", "filingdate": "TEXT", "rpo_b": "REAL"},
23
- "metadata": {},
24
- }
25
- return tools
26
-
27
-
28
- class PlotTests(unittest.TestCase):
29
- def setUp(self):
30
- network = patch("urllib.request.urlopen", side_effect=AssertionError("Unexpected network request"))
31
- network.start()
32
- self.addCleanup(network.stop)
33
- self.tools = saved_tools()
34
-
35
- def test_malformed_plot_has_actionable_error_and_preserves_state(self):
36
- with self.assertRaisesRegex(ValueError, r"missing required field\(s\): result_id") as caught:
37
- self.tools.call("plot", {**PLOT, "sql": "SELECT * FROM simple_xbrl"})
38
- self.assertIn("query_5", str(caught.exception))
39
- self.assertIn("filingdate, rpo_b", str(caught.exception))
40
- self.assertIn("SQLite SELECT FROM data", str(caught.exception))
41
- self.assertEqual(self.tools.artifacts, [])
42
- self.assertEqual(list(self.tools.results), ["query_5"])
43
-
44
- def test_separate_charts_reuse_saved_data(self):
45
- for index, company in enumerate(COMPANIES):
46
- result = self.tools.call("plot", {**PLOT, "title": company, "result_id": "query_5",
47
- "sql": f"SELECT * FROM data WHERE company = '{company}' ORDER BY filingdate"})
48
- self.assertEqual(result["artifact_id"], index + 1)
49
- self.assertEqual(result["row_count"], 2)
50
- saved = self.tools.results[result["result_id"]]
51
- self.assertEqual([row["company"] for row in saved["rows"]], [company, company])
52
- self.assertEqual(saved["source_result_id"], "query_5")
53
- self.assertEqual([artifact["title"] for artifact in self.tools.artifacts], COMPANIES)
54
- self.assertTrue(all(artifact["kind"] == "line" for artifact in self.tools.artifacts))
55
- self.assertEqual(len(self.tools.results["query_5"]["rows"]), 6)
56
-
57
- def test_sql_errors_can_be_corrected_without_fetching(self):
58
- for name in ["query", "plot"]:
59
- args = PLOT if name == "plot" else {}
60
- with self.assertRaisesRegex(ValueError, "Local SQLite query failed") as caught:
61
- self.tools.call(name, {**args, "result_id": "query_5", "sql": "SELECT * FROM simple_xbrl"})
62
- self.assertIn("columns: company, filingdate, rpo_b", str(caught.exception))
63
- filtered = self.tools.call("query", {"result_id": "query_5",
64
- "sql": "SELECT * FROM data WHERE company = 'Oracle'"})
65
- self.assertEqual(filtered["row_count"], 2)
66
- result = self.tools.call("plot", {**PLOT, "result_id": filtered["result_id"]})
67
- self.assertEqual(result["artifact_id"], 1)
68
-
69
- def test_invalid_arguments_are_specific(self):
70
- cases = [
71
- ({**PLOT, "result_id": "query_99"}, "existing saved result_id.*query_5"),
72
- ({**PLOT, "result_id": "query_5", "y": "missing"}, "Available columns: company, filingdate, rpo_b"),
73
- ({**PLOT, "result_id": "query_5", "extra": True}, r"unknown field\(s\): extra"),
74
- ({**PLOT, "result_id": None}, "plot.result_id must be a string"),
75
- ]
76
- for arguments, message in cases:
77
- with self.subTest(arguments=arguments), self.assertRaisesRegex(ValueError, message):
78
- self.tools.call("plot", arguments)
79
- with self.assertRaisesRegex(ValueError, "No results are saved yet"):
80
- Tools("test-key").call("plot", PLOT)
81
- self.assertEqual(self.tools.artifacts, [])
82
-
83
- def test_markdown_rejects_svg_chart_but_accepts_reports_and_code(self):
84
- with self.assertRaisesRegex(ValueError, "cannot render SVG charts.*plot"):
85
- self.tools.call("write_markdown", {"title": "Chart", "markdown": "# RPO\n<svg><path/></svg>"})
86
- self.assertEqual(self.tools.artifacts, [])
87
- self.tools.call("write_markdown", {"title": "Report", "markdown": "# RPO\nA text report."})
88
- self.tools.call("write_markdown", {"title": "Example", "markdown": "```html\n<svg></svg>\n```"})
89
- self.assertEqual(len(self.tools.artifacts), 2)
90
-
91
- def test_agent_recovers_from_rejected_plot_and_emits_separate_artifacts(self):
92
- agent = Agent(api_key="test-data-key", model_api_key="test-model-key")
93
- agent.tools.results = self.tools.results
94
- completions = 0
95
-
96
- def complete(tool_choice):
97
- nonlocal completions
98
- step = completions
99
- completions += 1
100
- if step == 1:
101
- rejection = json.loads(next(message["content"] for message in agent.messages
102
- if message["role"] == "tool"))
103
- self.assertIn("missing required field(s): result_id", rejection["error"])
104
- self.assertIn("query_5", rejection["error"])
105
- if step == 4:
106
- return {"choices": [{"message": {"content": "Created three charts."}}]}
107
- arguments = ({**PLOT, "sql": "SELECT * FROM simple_xbrl"} if step == 0 else
108
- {**PLOT, "title": COMPANIES[step - 1], "result_id": "query_5",
109
- "sql": f"SELECT * FROM data WHERE company = '{COMPANIES[step - 1]}'"})
110
- return {"choices": [{"message": {"tool_calls": [{"id": f"call_{step}", "type": "function",
111
- "function": {"name": "plot", "arguments": json.dumps(arguments)}}]}}]}
112
-
113
- with patch.object(agent, "_complete", side_effect=complete):
114
- events = list(agent.run("Plot each company separately."))
115
- outcomes = [event for event in events if event["type"] == "tool_result"]
116
- self.assertEqual(len(outcomes), 4)
117
- self.assertIn("result_id", outcomes[0]["result"]["error"])
118
- self.assertTrue(all(event["result"].get("artifact_id") for event in outcomes[1:]))
119
- self.assertEqual([event["artifact"]["title"] for event in events if event["type"] == "artifact"], COMPANIES)
120
- self.assertFalse(any(event["type"] == "error" for event in events))
121
- self.assertEqual(completions, 5)
122
-
123
- def test_tool_contract_and_instructions_match_typescript(self):
124
- config = (Path(__file__).resolve().parents[2] / "typescript/src/config.ts").read_text(encoding="utf-8")
125
- prefix = "export const INSTRUCTIONS = "
126
- line = next(line for line in config.splitlines() if line.startswith(prefix))
127
- self.assertEqual(INSTRUCTIONS, json.loads(line[len(prefix):-1]))
128
- definitions = json.loads(config.split("export const TOOL_DEFINITIONS = ", 1)[1].strip().removesuffix(";"))
129
- self.assertEqual(TOOL_DEFINITIONS, definitions)
130
-
131
-
132
- if __name__ == "__main__":
133
- unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes