rlmness-cli 0.2.0__py3-none-any.whl

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 (203) hide show
  1. rlmagent/__init__.py +1 -0
  2. rlmagent/__main__.py +8 -0
  3. rlmagent/acp/__init__.py +1 -0
  4. rlmagent/acp/runtime.py +71 -0
  5. rlmagent/acp/server.py +598 -0
  6. rlmagent/acp/updates.py +128 -0
  7. rlmagent/agentnet.py +237 -0
  8. rlmagent/agents/__init__.py +1 -0
  9. rlmagent/agents/approval.py +90 -0
  10. rlmagent/agents/bridge.py +109 -0
  11. rlmagent/agents/budget.py +212 -0
  12. rlmagent/agents/child_handles.py +46 -0
  13. rlmagent/agents/child_mailbox.py +30 -0
  14. rlmagent/agents/kernel_api.py +156 -0
  15. rlmagent/agents/output.py +34 -0
  16. rlmagent/agents/patch.py +107 -0
  17. rlmagent/agents/prompts.py +156 -0
  18. rlmagent/agents/trace.py +290 -0
  19. rlmagent/agents/tree.py +682 -0
  20. rlmagent/agents/wasm_api.py +105 -0
  21. rlmagent/agents/workspace.py +357 -0
  22. rlmagent/autopilot.py +447 -0
  23. rlmagent/backend.py +946 -0
  24. rlmagent/backend_context.py +74 -0
  25. rlmagent/cli/__init__.py +5 -0
  26. rlmagent/cli/config_cmd.py +177 -0
  27. rlmagent/cli/health.py +156 -0
  28. rlmagent/cli/main.py +1194 -0
  29. rlmagent/cli/update.py +188 -0
  30. rlmagent/clipboard.py +63 -0
  31. rlmagent/config/__init__.py +37 -0
  32. rlmagent/config/config.py +297 -0
  33. rlmagent/config/loader.py +119 -0
  34. rlmagent/config/prompts.py +124 -0
  35. rlmagent/config/secure_input.py +213 -0
  36. rlmagent/config/store.py +275 -0
  37. rlmagent/context/__init__.py +47 -0
  38. rlmagent/context/budget.py +557 -0
  39. rlmagent/contract.py +311 -0
  40. rlmagent/contract_run.py +232 -0
  41. rlmagent/conversation.py +2547 -0
  42. rlmagent/cron.py +77 -0
  43. rlmagent/directives.py +893 -0
  44. rlmagent/discovery.py +356 -0
  45. rlmagent/execution_log.py +66 -0
  46. rlmagent/factory.py +189 -0
  47. rlmagent/factory_graph.py +413 -0
  48. rlmagent/harness/__init__.py +63 -0
  49. rlmagent/harness/contracts/__init__.py +1 -0
  50. rlmagent/harness/contracts/stream.py +129 -0
  51. rlmagent/harness/contracts/tooling.py +127 -0
  52. rlmagent/harness/contracts/transcript/__init__.py +68 -0
  53. rlmagent/harness/contracts/transcript/_base.py +29 -0
  54. rlmagent/harness/contracts/transcript/diagnostics.py +52 -0
  55. rlmagent/harness/contracts/transcript/entries.py +180 -0
  56. rlmagent/harness/contracts/transcript/segments.py +64 -0
  57. rlmagent/harness/contracts/values.py +6 -0
  58. rlmagent/harness/driver.py +397 -0
  59. rlmagent/harness/engine.py +489 -0
  60. rlmagent/harness/mending.py +153 -0
  61. rlmagent/harness/provider/__init__.py +1 -0
  62. rlmagent/harness/provider/base.py +27 -0
  63. rlmagent/harness/provider/wire.py +189 -0
  64. rlmagent/harness/py.typed +0 -0
  65. rlmagent/harness/session/__init__.py +5 -0
  66. rlmagent/harness/session/index.py +218 -0
  67. rlmagent/harness/session/lease.py +66 -0
  68. rlmagent/harness/session/records.py +118 -0
  69. rlmagent/harness/session/replay.py +165 -0
  70. rlmagent/harness/session/store.py +155 -0
  71. rlmagent/harness/session/summary.py +46 -0
  72. rlmagent/hints.py +47 -0
  73. rlmagent/history.py +92 -0
  74. rlmagent/hooks/__init__.py +1 -0
  75. rlmagent/hooks/hooks.py +1 -0
  76. rlmagent/instructions.py +157 -0
  77. rlmagent/job_runner.py +117 -0
  78. rlmagent/jobs.py +365 -0
  79. rlmagent/kept_agents.py +397 -0
  80. rlmagent/kernel/__init__.py +5 -0
  81. rlmagent/kernel/session.py +205 -0
  82. rlmagent/kernel/wasm.py +169 -0
  83. rlmagent/logs.py +208 -0
  84. rlmagent/managed_release.py +368 -0
  85. rlmagent/mcp.py +411 -0
  86. rlmagent/mcp_oauth.py +213 -0
  87. rlmagent/model/__init__.py +29 -0
  88. rlmagent/model/_claude/__init__.py +1 -0
  89. rlmagent/model/_claude/courier.py +117 -0
  90. rlmagent/model/_claude/emitter.py +378 -0
  91. rlmagent/model/_oai/__init__.py +1 -0
  92. rlmagent/model/_oai/helpers.py +256 -0
  93. rlmagent/model/_oai/normalize.py +263 -0
  94. rlmagent/model/_oai/parsers.py +317 -0
  95. rlmagent/model/_oai/payloads.py +226 -0
  96. rlmagent/model/_oai/transport.py +127 -0
  97. rlmagent/model/claude.py +552 -0
  98. rlmagent/model/copilot.py +142 -0
  99. rlmagent/model/correlation.py +21 -0
  100. rlmagent/model/deadline.py +19 -0
  101. rlmagent/model/discovery.py +181 -0
  102. rlmagent/model/external/__init__.py +11 -0
  103. rlmagent/model/external/acp.py +202 -0
  104. rlmagent/model/external/acp_install.py +109 -0
  105. rlmagent/model/external/cli.py +248 -0
  106. rlmagent/model/external/textcalls.py +57 -0
  107. rlmagent/model/failover.py +129 -0
  108. rlmagent/model/google.py +199 -0
  109. rlmagent/model/limits.py +46 -0
  110. rlmagent/model/oai_compatible.py +414 -0
  111. rlmagent/model/preferences.py +56 -0
  112. rlmagent/model/py.typed +0 -0
  113. rlmagent/model/registered_oauth.py +195 -0
  114. rlmagent/model/scripted.py +55 -0
  115. rlmagent/model/settings.py +271 -0
  116. rlmagent/model/subscription.py +398 -0
  117. rlmagent/model/token_bounds.py +15 -0
  118. rlmagent/model/transport/__init__.py +1 -0
  119. rlmagent/model/transport/backoff.py +87 -0
  120. rlmagent/model/transport/client.py +87 -0
  121. rlmagent/model/transport/faults.py +65 -0
  122. rlmagent/model/transport/responses_socket.py +223 -0
  123. rlmagent/model/vertex.py +93 -0
  124. rlmagent/network.py +123 -0
  125. rlmagent/packages.py +246 -0
  126. rlmagent/planning.py +84 -0
  127. rlmagent/playbook/__init__.py +1 -0
  128. rlmagent/playbook/apply.py +106 -0
  129. rlmagent/playbook/digest.py +63 -0
  130. rlmagent/playbook/improver.py +272 -0
  131. rlmagent/playbook/plan.py +99 -0
  132. rlmagent/playbook/store.py +67 -0
  133. rlmagent/plugins.py +327 -0
  134. rlmagent/policy.py +105 -0
  135. rlmagent/prompt_refs.py +57 -0
  136. rlmagent/prompts.py +331 -0
  137. rlmagent/prune.py +60 -0
  138. rlmagent/py.typed +0 -0
  139. rlmagent/reasoning.py +135 -0
  140. rlmagent/refinement/__init__.py +1 -0
  141. rlmagent/refinement/controller.py +448 -0
  142. rlmagent/refinement/engine.py +220 -0
  143. rlmagent/refinement/evidence.py +131 -0
  144. rlmagent/refinement/scoped.py +90 -0
  145. rlmagent/refinement/store.py +193 -0
  146. rlmagent/refresh.py +202 -0
  147. rlmagent/routing.py +52 -0
  148. rlmagent/runtime.py +575 -0
  149. rlmagent/safety.py +267 -0
  150. rlmagent/session_html.py +57 -0
  151. rlmagent/session_import.py +52 -0
  152. rlmagent/session_share.py +55 -0
  153. rlmagent/sessions.py +249 -0
  154. rlmagent/shell_sandbox.py +88 -0
  155. rlmagent/skillset.py +320 -0
  156. rlmagent/steering.py +148 -0
  157. rlmagent/stops.py +62 -0
  158. rlmagent/subscriptions.py +219 -0
  159. rlmagent/tasks.py +98 -0
  160. rlmagent/toolhost.py +310 -0
  161. rlmagent/tools/__init__.py +56 -0
  162. rlmagent/tools/_shared.py +210 -0
  163. rlmagent/tools/files.py +462 -0
  164. rlmagent/tools/kernel.py +93 -0
  165. rlmagent/tools/patch_tool.py +68 -0
  166. rlmagent/tools/search.py +188 -0
  167. rlmagent/trust.py +383 -0
  168. rlmagent/tui/__init__.py +1 -0
  169. rlmagent/tui/adapter.py +506 -0
  170. rlmagent/tui/app.py +711 -0
  171. rlmagent/tui/approval_screen.py +57 -0
  172. rlmagent/tui/choice_screen.py +47 -0
  173. rlmagent/tui/completions.py +116 -0
  174. rlmagent/tui/components.py +25 -0
  175. rlmagent/tui/composer.py +40 -0
  176. rlmagent/tui/factory_view.py +86 -0
  177. rlmagent/tui/inspect.py +193 -0
  178. rlmagent/tui/messages.py +215 -0
  179. rlmagent/tui/model_screen.py +307 -0
  180. rlmagent/tui/prompt.py +98 -0
  181. rlmagent/tui/question_screen.py +52 -0
  182. rlmagent/tui/replay.py +141 -0
  183. rlmagent/tui/rlm_agent.tcss +405 -0
  184. rlmagent/tui/search_box.py +91 -0
  185. rlmagent/tui/sidebar.py +182 -0
  186. rlmagent/tui/status.py +49 -0
  187. rlmagent/tui/steps.py +283 -0
  188. rlmagent/tui/suggest.py +778 -0
  189. rlmagent/tui/transcript.py +172 -0
  190. rlmagent/tui/trust_dialog.py +58 -0
  191. rlmagent/tui/worker_attach.py +141 -0
  192. rlmagent/ui/__init__.py +1 -0
  193. rlmagent/ui/render.py +211 -0
  194. rlmagent/undo.py +109 -0
  195. rlmagent/utils/__init__.py +1 -0
  196. rlmagent/websearch.py +75 -0
  197. rlmagent/worker.py +1275 -0
  198. rlmagent/worker_service.py +203 -0
  199. rlmness_cli-0.2.0.dist-info/METADATA +882 -0
  200. rlmness_cli-0.2.0.dist-info/RECORD +203 -0
  201. rlmness_cli-0.2.0.dist-info/WHEEL +4 -0
  202. rlmness_cli-0.2.0.dist-info/entry_points.txt +2 -0
  203. rlmness_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,93 @@
1
+ """The python tool: run a cell in this agent's persistent kernel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Mapping
7
+
8
+ from rlmagent.kernel import CellResult, KernelSession
9
+ from rlmagent.tools._shared import (
10
+ CancelToken,
11
+ JValue,
12
+ ProgressNotifier,
13
+ ToolOutcome,
14
+ ToolSpec,
15
+ )
16
+
17
+ _DESCRIPTION = (
18
+ "Run Python in a persistent IPython kernel whose working directory is the "
19
+ "project root. Variables, imports and functions stay defined between calls. "
20
+ "Run shell commands with a leading `!` or with subprocess. Top-level `await` works."
21
+ )
22
+
23
+ _PARAMETERS: Mapping[str, JValue] = {
24
+ "type": "object",
25
+ "properties": {"code": {"type": "string", "description": "The cell to execute in the kernel."}},
26
+ "required": ["code"],
27
+ }
28
+
29
+
30
+ def format_cell(result: CellResult) -> str:
31
+ parts: list[str] = []
32
+ if result.restarted and not result.timed_out:
33
+ parts.append(
34
+ "[The kernel had stopped and was restarted: variables from before are gone.]"
35
+ )
36
+ if result.output:
37
+ parts.append(result.output.rstrip("\n"))
38
+ if result.error:
39
+ parts.append(result.error)
40
+ if result.timed_out and result.restarted:
41
+ parts.append(
42
+ "[The cell timed out and ignored the interrupt, so the kernel was restarted: "
43
+ "variables from before are gone.]"
44
+ )
45
+ elif result.timed_out:
46
+ parts.append("[The cell timed out and was interrupted; state up to that point is kept.]")
47
+ return "\n".join(parts) if parts else "(no output)"
48
+
49
+
50
+ _CANCEL_GRACE = 3.0
51
+
52
+
53
+ async def _cancel(kernel: KernelSession, task: asyncio.Task) -> str:
54
+ """Stop a running cell for a cancel, and never wait long for it."""
55
+ await kernel.interrupt()
56
+ done, _ = await asyncio.wait({task}, timeout=_CANCEL_GRACE)
57
+ if done:
58
+ return format_cell(task.result()) + "\n[Cancelled by the user.]"
59
+ await kernel.kill()
60
+ await asyncio.gather(task, return_exceptions=True)
61
+ return (
62
+ "[Cancelled by the user. The cell ignored the interrupt, so the kernel was "
63
+ "stopped: variables from before are gone.]"
64
+ )
65
+
66
+
67
+ def make_python_tool(kernel: KernelSession) -> ToolSpec:
68
+ async def run(
69
+ tool_call_id: str,
70
+ arguments: Mapping[str, JValue],
71
+ signal: CancelToken | None = None,
72
+ on_update: ProgressNotifier | None = None,
73
+ ) -> ToolOutcome:
74
+ code = str(arguments.get("code", ""))
75
+ task = asyncio.create_task(kernel.run(code))
76
+ try:
77
+ while not task.done():
78
+ if signal is not None and signal.is_cancelled():
79
+ return ToolOutcome(content=await _cancel(kernel, task))
80
+ await asyncio.sleep(0.2)
81
+ except asyncio.CancelledError:
82
+ await kernel.interrupt()
83
+ raise
84
+ return ToolOutcome(content=format_cell(await task))
85
+
86
+ return ToolSpec(
87
+ name="python",
88
+ label="Python",
89
+ description=_DESCRIPTION,
90
+ parameters=_PARAMETERS,
91
+ run=run,
92
+ parallelism="sequential",
93
+ )
@@ -0,0 +1,68 @@
1
+ """ApplyPatch: several edits across files from one unified diff, all of them or none."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ from rlmagent.tools._shared import (
9
+ CancelToken,
10
+ JValue,
11
+ ProgressNotifier,
12
+ ToolOutcome,
13
+ ToolSpec,
14
+ fault,
15
+ require_str,
16
+ text_outcome,
17
+ )
18
+
19
+ _PARAMS: Mapping[str, JValue] = {
20
+ "type": "object",
21
+ "properties": {
22
+ "patch": {
23
+ "type": "string",
24
+ "description": (
25
+ "A unified diff with ---/+++ headers and @@ hunks. Paths are relative to the "
26
+ "working directory; /dev/null creates or deletes a file."
27
+ ),
28
+ },
29
+ },
30
+ "required": ["patch"],
31
+ "additionalProperties": False,
32
+ }
33
+
34
+
35
+ async def _run_patch(
36
+ tool_call_id: str,
37
+ arguments: Mapping[str, JValue],
38
+ signal: CancelToken | None = None,
39
+ on_update: ProgressNotifier | None = None,
40
+ ) -> ToolOutcome:
41
+ from rlmagent.agents.approval import Approver
42
+ from rlmagent.agents.patch import PatchError
43
+ from rlmagent.agents.workspace import Workspace
44
+
45
+ try:
46
+ diff = require_str(arguments, "patch")
47
+ # Approval for this tool happens before it runs, like Write and Edit.
48
+ return text_outcome(await Workspace(Path.cwd(), Approver("auto")).apply_patch(diff))
49
+ except (PatchError, ValueError, OSError) as exc:
50
+ return fault(f"Patch not applied: {exc}")
51
+
52
+
53
+ def make_patch_tool() -> ToolSpec:
54
+ return ToolSpec(
55
+ name="ApplyPatch",
56
+ label="Apply Patch",
57
+ description=(
58
+ "Apply a unified diff that may change, create or delete several files. Every hunk "
59
+ "is checked first; if any does not match, no file is changed."
60
+ ),
61
+ parameters=_PARAMS,
62
+ run=_run_patch,
63
+ parallelism="sequential",
64
+ format_call=lambda a: f"{str(a.get('patch', '')).count('+++ ')} files",
65
+ )
66
+
67
+
68
+ __all__ = ["make_patch_tool"]
@@ -0,0 +1,188 @@
1
+ """Search tools: Grep for file contents, Glob for file names. Both only read."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import os
7
+ import re
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+
11
+ from rlmagent.tools._shared import (
12
+ CancelToken,
13
+ JValue,
14
+ ProgressNotifier,
15
+ ToolOutcome,
16
+ ToolSpec,
17
+ fault,
18
+ optional_bool,
19
+ optional_int,
20
+ require_str,
21
+ text_outcome,
22
+ )
23
+
24
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", ".rlm-agent"}
25
+ MAX_FILE_BYTES = 2_000_000
26
+ MAX_LINE_CHARS = 300
27
+ DEFAULT_MATCHES = 200
28
+ MAX_MATCHES = 1_000
29
+ MAX_NAMES = 1_000
30
+
31
+
32
+ def _start(arguments: Mapping[str, JValue]) -> Path:
33
+ raw = arguments.get("path")
34
+ path = Path(raw) if isinstance(raw, str) and raw.strip() else Path.cwd()
35
+ if not path.is_absolute():
36
+ path = Path.cwd() / path
37
+ if not path.exists():
38
+ raise ValueError(f"Path not found: {path}")
39
+ return path
40
+
41
+
42
+ def _files(base: Path, glob: str | None):
43
+ if base.is_file():
44
+ yield base
45
+ return
46
+ for folder, dirs, names in os.walk(base, followlinks=False):
47
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
48
+ for name in sorted(names):
49
+ file = Path(folder) / name
50
+ if glob:
51
+ rel = file.relative_to(base).as_posix()
52
+ if not (fnmatch.fnmatch(name, glob) or fnmatch.fnmatch(rel, glob)):
53
+ continue
54
+ yield file
55
+
56
+
57
+ # ═══════════════════════════════════════════════════════════════════════════
58
+ # Grep tool
59
+ # ═══════════════════════════════════════════════════════════════════════════
60
+
61
+ _GREP_PARAMS: Mapping[str, JValue] = {
62
+ "type": "object",
63
+ "properties": {
64
+ "pattern": {"type": "string", "description": "Regular expression to look for."},
65
+ "path": {
66
+ "type": "string",
67
+ "description": "File or folder to search. Defaults to the working directory.",
68
+ },
69
+ "glob": {
70
+ "type": "string",
71
+ "description": "Only search files whose name or relative path matches, e.g. *.py.",
72
+ },
73
+ "ignore_case": {"type": "boolean", "description": "Match without regard to case."},
74
+ "limit": {
75
+ "type": "integer",
76
+ "description": f"Most matching lines to return (default {DEFAULT_MATCHES}).",
77
+ },
78
+ },
79
+ "required": ["pattern"],
80
+ "additionalProperties": False,
81
+ }
82
+
83
+
84
+ async def _run_grep(
85
+ tool_call_id: str,
86
+ arguments: Mapping[str, JValue],
87
+ signal: CancelToken | None = None,
88
+ on_update: ProgressNotifier | None = None,
89
+ ) -> ToolOutcome:
90
+ try:
91
+ regex = re.compile(
92
+ require_str(arguments, "pattern"),
93
+ re.IGNORECASE if optional_bool(arguments, "ignore_case") else 0,
94
+ )
95
+ base = _start(arguments)
96
+ except (ValueError, re.error) as exc:
97
+ return fault(str(exc))
98
+ glob = arguments.get("glob") if isinstance(arguments.get("glob"), str) else None
99
+ limit = max(1, min(optional_int(arguments, "limit") or DEFAULT_MATCHES, MAX_MATCHES))
100
+ lines: list[str] = []
101
+ for file in _files(base, glob):
102
+ try:
103
+ if file.stat().st_size > MAX_FILE_BYTES:
104
+ continue
105
+ with open(file, encoding="utf-8") as f:
106
+ for number, line in enumerate(f, 1):
107
+ if regex.search(line):
108
+ lines.append(f"{file}:{number}: {line.rstrip()[:MAX_LINE_CHARS]}")
109
+ if len(lines) >= limit:
110
+ lines.append(f"(stopped at {limit} matches; narrow the search)")
111
+ return text_outcome("\n".join(lines))
112
+ except (UnicodeDecodeError, OSError):
113
+ continue
114
+ return text_outcome("\n".join(lines) if lines else "No matches.")
115
+
116
+
117
+ def make_grep_tool() -> ToolSpec:
118
+ return ToolSpec(
119
+ name="Grep",
120
+ label="Search Files",
121
+ description=(
122
+ "Search file contents with a regular expression. Returns path:line: text "
123
+ "for each matching line. Skips .git, virtual environments and binary files."
124
+ ),
125
+ parameters=_GREP_PARAMS,
126
+ run=_run_grep,
127
+ parallelism="parallel",
128
+ format_call=lambda a: f"{a.get('pattern', '?')} in {a.get('path') or '.'}",
129
+ )
130
+
131
+
132
+ # ═══════════════════════════════════════════════════════════════════════════
133
+ # Glob tool
134
+ # ═══════════════════════════════════════════════════════════════════════════
135
+
136
+ _GLOB_PARAMS: Mapping[str, JValue] = {
137
+ "type": "object",
138
+ "properties": {
139
+ "pattern": {"type": "string", "description": "File name pattern, e.g. **/*.py."},
140
+ "path": {
141
+ "type": "string",
142
+ "description": "Folder to search in. Defaults to the working directory.",
143
+ },
144
+ },
145
+ "required": ["pattern"],
146
+ "additionalProperties": False,
147
+ }
148
+
149
+
150
+ async def _run_glob(
151
+ tool_call_id: str,
152
+ arguments: Mapping[str, JValue],
153
+ signal: CancelToken | None = None,
154
+ on_update: ProgressNotifier | None = None,
155
+ ) -> ToolOutcome:
156
+ try:
157
+ pattern = require_str(arguments, "pattern")
158
+ base = _start(arguments)
159
+ except ValueError as exc:
160
+ return fault(str(exc))
161
+ names: list[str] = []
162
+ for path in sorted(base.glob(pattern)):
163
+ rel = path.relative_to(base)
164
+ if path.is_file() and not SKIP_DIRS.intersection(rel.parts):
165
+ names.append(str(path))
166
+ if len(names) >= MAX_NAMES:
167
+ names.append(f"(stopped at {MAX_NAMES} files; narrow the pattern)")
168
+ break
169
+ return text_outcome("\n".join(names) if names else "No files found.")
170
+
171
+
172
+ def make_glob_tool() -> ToolSpec:
173
+ return ToolSpec(
174
+ name="Glob",
175
+ label="Find Files",
176
+ description="Find files by name pattern, such as **/*.py or src/**/test_*.py.",
177
+ parameters=_GLOB_PARAMS,
178
+ run=_run_glob,
179
+ parallelism="parallel",
180
+ format_call=lambda a: f"{a.get('pattern', '?')} in {a.get('path') or '.'}",
181
+ )
182
+
183
+
184
+ def search_tools() -> list[ToolSpec]:
185
+ return [make_grep_tool(), make_glob_tool()]
186
+
187
+
188
+ __all__ = ["make_glob_tool", "make_grep_tool", "search_tools"]