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.
- {mulebot-0.1.2 → mulebot-0.2.0}/PKG-INFO +1 -1
- mulebot-0.2.0/mulebot/__init__.py +4 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/agent.py +76 -21
- mulebot-0.2.0/mulebot/tool.py +139 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/tools.py +24 -21
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/PKG-INFO +1 -1
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/SOURCES.txt +2 -2
- {mulebot-0.1.2 → mulebot-0.2.0}/setup.py +1 -1
- mulebot-0.1.2/mulebot/__init__.py +0 -3
- mulebot-0.1.2/tests/test_plot.py +0 -133
- {mulebot-0.1.2 → mulebot-0.2.0}/LICENSE +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/MANIFEST.in +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/__main__.py +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/assets.py +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/cli.py +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/datasets.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/docs.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/index.yaml +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/products.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/readme.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/schema.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec-filings-lookup.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec_filings_archive.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/sec_filings_notifications.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/transfer_to_your_cloud.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/docs/utils.md +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/server.py +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/static/app.js +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/static/style.css +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot/ui/templates/index.html +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/dependency_links.txt +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/entry_points.txt +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/requires.txt +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/mulebot.egg-info/top_level.txt +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/pyproject.toml +0 -0
- {mulebot-0.1.2 → mulebot-0.2.0}/setup.cfg +0 -0
|
@@ -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
|
|
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.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
self.messages = [
|
|
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.
|
|
79
|
-
"artifacts": self.
|
|
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.
|
|
133
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
237
|
-
first_artifact = len(self.
|
|
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.
|
|
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.
|
|
257
|
-
yield {"type": "query_result", "result_id": result_id, **self.
|
|
258
|
-
for i in range(first_artifact, len(self.
|
|
259
|
-
yield {"type": "artifact", "artifact_id": i + 1, "artifact": self.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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)
|
|
@@ -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
|
mulebot-0.1.2/tests/test_plot.py
DELETED
|
@@ -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
|
|
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
|