pyagent-harness 0.1.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.
pyagent/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __all__ = []
pyagent/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
pyagent/agent.py ADDED
@@ -0,0 +1,484 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ from typing import Any
6
+
7
+ from .config import AppConfig, SYSTEM_PROMPT
8
+ from .external_tools import (
9
+ DiscoveryResult,
10
+ build_external_tool_specs,
11
+ default_runner_command,
12
+ discover_external_tools,
13
+ )
14
+ from .llm_client import build_chat_client
15
+ from .model_profiles import ModelProfile, ProfileStore, load_profile_store, save_profile_store, update_profile_store
16
+ from .tools import ToolRegistry, create_default_tool_registry
17
+
18
+
19
+ class Agent:
20
+ def __init__(
21
+ self,
22
+ model: str | None = None,
23
+ profile: str | None = None,
24
+ config: AppConfig | None = None,
25
+ tool_registry: ToolRegistry | None = None,
26
+ project_context: str = "",
27
+ project_context_files: list[str] | None = None,
28
+ external_tool_discovery: DiscoveryResult | None = None,
29
+ ):
30
+ self.config = config or AppConfig.from_env()
31
+ self.profile_store: ProfileStore = load_profile_store(
32
+ self.config.model_profiles_path
33
+ )
34
+ self.active_profile_name = profile or self.config.default_profile or self.profile_store.default_profile
35
+ self.model_override = model.strip() if model else None
36
+ self.external_tool_discovery: DiscoveryResult | None = external_tool_discovery
37
+ if tool_registry is None:
38
+ self.external_tool_discovery = (
39
+ external_tool_discovery
40
+ if external_tool_discovery is not None
41
+ else self._discover_external_tools()
42
+ )
43
+ self.tool_registry = create_default_tool_registry(
44
+ self.config,
45
+ external_specs=self._external_specs_from_discovery(
46
+ self.external_tool_discovery),
47
+ )
48
+ else:
49
+ self.tool_registry = tool_registry
50
+ self.tools = self.tool_registry.definitions() if self.config.tools_enabled else []
51
+ self.project_context = project_context.strip()
52
+ self.project_context_files = list(project_context_files or [])
53
+ self.prompt_file_created = False
54
+ self.prompt_file_path = None
55
+
56
+ created, path = self._ensure_system_prompt_file()
57
+ if created:
58
+ self.prompt_file_created = True
59
+ self.prompt_file_path = path
60
+
61
+ self._rebuild_client()
62
+ self.reset()
63
+
64
+ def _discover_external_tools(self) -> DiscoveryResult | None:
65
+ if not self.config.user_tools_enabled:
66
+ return None
67
+ try:
68
+ return discover_external_tools(
69
+ user_dir=self.config.user_dir,
70
+ runner=self.config.tool_runner,
71
+ describe_timeout=self.config.user_tool_describe_timeout,
72
+ )
73
+ except Exception:
74
+ return None
75
+
76
+ def _external_specs_from_discovery(self, discovery: DiscoveryResult | None):
77
+ if discovery is None:
78
+ return None
79
+ return build_external_tool_specs(
80
+ discovery,
81
+ invoke_timeout=self.config.user_tool_timeout,
82
+ runner_command=default_runner_command(discovery.runner),
83
+ )
84
+
85
+ def reload_external_tools(self) -> DiscoveryResult | None:
86
+ """Re-scan ``~/.pyagent/tools/`` and rebuild the tool registry.
87
+
88
+ Tool calling state is preserved (`tools_enabled`, system prompt
89
+ composition); only the registry contents change. The conversation
90
+ is reset so the model sees a clean tool surface.
91
+ """
92
+ discovery = self._discover_external_tools()
93
+ self.external_tool_discovery = discovery
94
+ self.tool_registry = create_default_tool_registry(
95
+ self.config,
96
+ external_specs=self._external_specs_from_discovery(discovery),
97
+ )
98
+ self.tools = self.tool_registry.definitions() if self.config.tools_enabled else []
99
+ self.reset()
100
+ return discovery
101
+
102
+ def _ensure_system_prompt_file(self) -> tuple[bool, str | None]:
103
+ path = self.config.system_prompt_path
104
+ if os.path.exists(path):
105
+ return False, None
106
+
107
+ try:
108
+ os.makedirs(os.path.dirname(path), exist_ok=True)
109
+ with open(path, "w", encoding="utf-8") as f:
110
+ f.write(SYSTEM_PROMPT)
111
+ return True, path
112
+ except (IOError, OSError) as exc:
113
+ return False, str(exc)
114
+
115
+ def _system_prompt(self) -> str:
116
+ try:
117
+ with open(self.config.system_prompt_path, "r", encoding="utf-8") as f:
118
+ prompt = f.read().strip()
119
+ if not prompt:
120
+ prompt = SYSTEM_PROMPT
121
+ except (FileNotFoundError, IOError):
122
+ prompt = SYSTEM_PROMPT
123
+
124
+ if not self.config.tools_enabled:
125
+ prompt += (
126
+ "\n\nTool calling is disabled for this session. Do not call tools. "
127
+ "Answer using only the conversation and your built-in knowledge."
128
+ )
129
+ if not self.project_context:
130
+ return prompt
131
+ return f"{prompt}\n\n{self.project_context}"
132
+
133
+ def _rebuild_client(self) -> None:
134
+ existing_client = getattr(self, "client", None)
135
+ if existing_client is not None:
136
+ close = getattr(existing_client, "close", None)
137
+ if callable(close):
138
+ close()
139
+
140
+ profile = self.profile_store.get(self.active_profile_name)
141
+ if self.model_override:
142
+ profile = ModelProfile(
143
+ name=profile.name,
144
+ provider=profile.provider,
145
+ model=self.model_override,
146
+ base_url=profile.base_url,
147
+ api_key=profile.api_key,
148
+ api_key_env=profile.api_key_env,
149
+ headers=dict(profile.headers),
150
+ )
151
+ self.client = build_chat_client(
152
+ profile, timeout=self.config.request_timeout)
153
+
154
+ def current_profile(self) -> ModelProfile:
155
+ return self.client.profile
156
+
157
+ def profile_names(self) -> list[str]:
158
+ return self.profile_store.names()
159
+
160
+ def set_project_context(self, project_context: str, files: list[str] | None = None) -> None:
161
+ self.project_context = project_context.strip()
162
+ self.project_context_files = list(files or [])
163
+ self.reset()
164
+
165
+ def set_profile(self, profile: str) -> None:
166
+ self.active_profile_name = profile.strip()
167
+ self.model_override = None
168
+ self._rebuild_client()
169
+
170
+ def reload_profiles(self) -> None:
171
+ current_profile_name = self.active_profile_name
172
+ self.profile_store = load_profile_store(
173
+ self.config.model_profiles_path)
174
+ if current_profile_name not in self.profile_store.profiles:
175
+ self.active_profile_name = self.profile_store.default_profile
176
+ self.model_override = None
177
+ self._rebuild_client()
178
+
179
+ def save_profile(self, profile: ModelProfile, make_default: bool = False) -> None:
180
+ update_profile_store(self.profile_store, profile,
181
+ make_default=make_default)
182
+ save_profile_store(self.profile_store)
183
+ self.profile_store = load_profile_store(self.profile_store.path)
184
+
185
+ def set_model(self, model: str) -> None:
186
+ self.model_override = model.strip()
187
+ self._rebuild_client()
188
+
189
+ def set_tools_enabled(self, enabled: bool) -> None:
190
+ self.config.tools_enabled = enabled
191
+ self.tools = self.tool_registry.definitions() if enabled else []
192
+ self.reset()
193
+
194
+ def available_models(self) -> tuple[list[str], str | None]:
195
+ response = self.client.list_models()
196
+ if "error" in response:
197
+ return [], str(response["error"])
198
+
199
+ names = [name for name in response.get(
200
+ "models", []) if isinstance(name, str) and name]
201
+ return names, None
202
+
203
+ def reset(self) -> None:
204
+ self.messages: list[dict[str, Any]] = [
205
+ {"role": "system", "content": self._system_prompt()}
206
+ ]
207
+
208
+ def add_message(self, role: str, content: str, **extra: Any) -> None:
209
+ message = {"role": role, "content": content}
210
+ message.update(extra)
211
+ self.messages.append(message)
212
+ self._trim_history()
213
+
214
+ def _trim_history(self) -> None:
215
+ max_history = self.config.max_history_messages
216
+ if len(self.messages) <= max_history + 1:
217
+ return
218
+
219
+ system_message = self.messages[0]
220
+ blocks: list[list[dict[str, Any]]] = []
221
+ index = 1
222
+ while index < len(self.messages):
223
+ message = self.messages[index]
224
+ role = message.get("role")
225
+
226
+ if role == "tool":
227
+ index += 1
228
+ continue
229
+
230
+ if role == "assistant" and message.get("tool_calls"):
231
+ block = [message]
232
+ index += 1
233
+ while index < len(self.messages) and self.messages[index].get("role") == "tool":
234
+ block.append(self.messages[index])
235
+ index += 1
236
+ blocks.append(block)
237
+ continue
238
+
239
+ blocks.append([message])
240
+ index += 1
241
+
242
+ kept_blocks: list[list[dict[str, Any]]] = []
243
+ kept_count = 0
244
+ for block in reversed(blocks):
245
+ block_size = len(block)
246
+ if kept_blocks and kept_count + block_size > max_history:
247
+ break
248
+ kept_blocks.append(block)
249
+ kept_count += block_size
250
+ if kept_count >= max_history:
251
+ break
252
+
253
+ trimmed_messages = [message for block in reversed(
254
+ kept_blocks) for message in block]
255
+ self.messages = [system_message, *trimmed_messages]
256
+
257
+ def _normalize_arguments(self, arguments: Any) -> dict[str, Any]:
258
+ if isinstance(arguments, dict):
259
+ return arguments
260
+ if isinstance(arguments, str):
261
+ try:
262
+ parsed = json.loads(arguments)
263
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
264
+ except json.JSONDecodeError:
265
+ return {}
266
+ return {}
267
+
268
+ def _normalize_tool_call(
269
+ self,
270
+ tool_call: dict[str, Any],
271
+ iteration: int,
272
+ index: int,
273
+ ) -> tuple[str, dict[str, Any], str]:
274
+ function = tool_call.get("function", {})
275
+ name = function.get("name", "")
276
+ arguments = self._normalize_arguments(function.get("arguments", {}))
277
+ tool_call_id = tool_call.get("id") or str(
278
+ function.get("index", f"call_{iteration}_{index}")
279
+ )
280
+ return name, arguments, tool_call_id
281
+
282
+ def _merge_tool_calls(
283
+ self,
284
+ existing: list[dict[str, Any]],
285
+ incoming: list[dict[str, Any]],
286
+ ) -> list[dict[str, Any]]:
287
+ merged: dict[str, dict[str, Any]] = {}
288
+ ordered_keys: list[str] = []
289
+
290
+ for tool_call in [*existing, *incoming]:
291
+ function = tool_call.get("function", {})
292
+ key = str(
293
+ tool_call.get("id")
294
+ or function.get("index")
295
+ or f"{function.get('name', '')}:{json.dumps(function.get('arguments', {}), sort_keys=True, default=str)}"
296
+ )
297
+ if key not in merged:
298
+ merged[key] = {
299
+ **tool_call,
300
+ "function": {
301
+ **function,
302
+ "arguments": function.get("arguments", {}),
303
+ },
304
+ }
305
+ ordered_keys.append(key)
306
+ continue
307
+
308
+ current = merged[key]
309
+ current_function = current.setdefault("function", {})
310
+ if function.get("name"):
311
+ current_function["name"] = function["name"]
312
+
313
+ incoming_arguments = function.get("arguments", {})
314
+ existing_arguments = current_function.get("arguments", {})
315
+ if isinstance(existing_arguments, dict) and isinstance(incoming_arguments, dict):
316
+ current_function["arguments"] = {
317
+ **existing_arguments,
318
+ **incoming_arguments,
319
+ }
320
+ elif isinstance(existing_arguments, str) and isinstance(incoming_arguments, str):
321
+ current_function["arguments"] = existing_arguments + \
322
+ incoming_arguments
323
+ elif incoming_arguments:
324
+ current_function["arguments"] = incoming_arguments
325
+
326
+ return [merged[key] for key in ordered_keys]
327
+
328
+ def _format_tool_results_for_fallback(self, tool_results: list[dict[str, Any]]) -> str:
329
+ if not tool_results:
330
+ return ""
331
+
332
+ if len(tool_results) == 1:
333
+ result = str(tool_results[0]["result"]).strip()
334
+ return f"\n\n```text\n{result}\n```" if result else ""
335
+
336
+ blocks: list[str] = []
337
+ for tool_result in tool_results:
338
+ result = str(tool_result["result"]).strip()
339
+ if not result:
340
+ continue
341
+ blocks.append(
342
+ f"**{tool_result['name']}**\n\n```text\n{result}\n```")
343
+ if not blocks:
344
+ return ""
345
+ return "\n\nHere are the tool results:\n\n" + "\n\n".join(blocks)
346
+
347
+ def _should_append_tool_results(self, content: str, tool_results: list[dict[str, Any]]) -> bool:
348
+ if not tool_results:
349
+ return False
350
+
351
+ stripped = content.strip()
352
+ if not stripped:
353
+ return True
354
+
355
+ return stripped.endswith(":")
356
+
357
+ def run(self, user_input: str):
358
+ self.add_message("user", user_input)
359
+ yield {"type": "assistant_start"}
360
+ recent_tool_results: list[dict[str, Any]] = []
361
+
362
+ iteration = 0
363
+ while self.config.max_iterations < 0 or iteration < self.config.max_iterations:
364
+ full_response_parts: list[str] = []
365
+ full_tool_calls: list[dict[str, Any]] = []
366
+ yield {
367
+ "type": "debug",
368
+ "label": "iteration_start",
369
+ "data": {"iteration": iteration + 1, "message_count": len(self.messages)},
370
+ }
371
+
372
+ for chunk in self.client.chat_stream(
373
+ self.messages,
374
+ tools=self.tools if self.config.tools_enabled else None,
375
+ ):
376
+ yield {"type": "debug", "label": "llm_chunk", "data": chunk}
377
+ if "error" in chunk:
378
+ error_message = f"API Error: {chunk['error']}"
379
+ yield {"type": "error", "message": error_message}
380
+ return
381
+
382
+ content = chunk.get("content") or ""
383
+ if content:
384
+ full_response_parts.append(content)
385
+ yield {"type": "content_delta", "delta": content}
386
+
387
+ tool_calls = chunk.get("tool_calls") or []
388
+ if tool_calls:
389
+ full_tool_calls = self._merge_tool_calls(
390
+ full_tool_calls, tool_calls)
391
+ yield {
392
+ "type": "debug",
393
+ "label": "merged_tool_calls",
394
+ "data": full_tool_calls,
395
+ }
396
+
397
+ assistant_message: dict[str, Any] = {
398
+ "role": "assistant",
399
+ "content": "".join(full_response_parts),
400
+ }
401
+ if full_tool_calls:
402
+ assistant_message["tool_calls"] = full_tool_calls
403
+ self.messages.append(assistant_message)
404
+ self._trim_history()
405
+
406
+ if not full_tool_calls:
407
+ if self._should_append_tool_results(
408
+ assistant_message["content"], recent_tool_results
409
+ ):
410
+ suffix = self._format_tool_results_for_fallback(
411
+ recent_tool_results)
412
+ if suffix:
413
+ assistant_message["content"] += suffix
414
+ self.messages[-1]["content"] = assistant_message["content"]
415
+ yield {
416
+ "type": "debug",
417
+ "label": "assistant_tool_result_fallback",
418
+ "data": {"tool_count": len(recent_tool_results)},
419
+ }
420
+ yield {"type": "content_delta", "delta": suffix}
421
+ yield {
422
+ "type": "debug",
423
+ "label": "assistant_message_complete",
424
+ "data": assistant_message,
425
+ }
426
+ yield {
427
+ "type": "assistant_done",
428
+ "content": assistant_message["content"],
429
+ }
430
+ return
431
+
432
+ for index, tool_call in enumerate(full_tool_calls):
433
+ name, arguments, tool_call_id = self._normalize_tool_call(
434
+ tool_call,
435
+ iteration,
436
+ index,
437
+ )
438
+ if not name:
439
+ result = "Error: received malformed tool call from model."
440
+ self.messages.append(
441
+ {
442
+ "role": "tool",
443
+ "name": "<invalid>",
444
+ "content": result,
445
+ "tool_call_id": tool_call_id,
446
+ }
447
+ )
448
+ recent_tool_results.append(
449
+ {"name": "<invalid>", "arguments": {}, "result": result}
450
+ )
451
+ yield {"type": "tool_result", "name": "<invalid>", "result": result}
452
+ continue
453
+
454
+ yield {"type": "tool_call", "name": name, "arguments": arguments}
455
+ result = self.tool_registry.execute(name, arguments)
456
+ yield {
457
+ "type": "debug",
458
+ "label": "tool_execution",
459
+ "data": {
460
+ "name": name,
461
+ "arguments": arguments,
462
+ "result_preview": result[:1000],
463
+ },
464
+ }
465
+ self.messages.append(
466
+ {
467
+ "role": "tool",
468
+ "name": name,
469
+ "content": result,
470
+ "tool_call_id": tool_call_id,
471
+ }
472
+ )
473
+ recent_tool_results.append(
474
+ {"name": name, "arguments": arguments, "result": result}
475
+ )
476
+ self._trim_history()
477
+ yield {"type": "tool_result", "name": name, "result": result}
478
+
479
+ iteration += 1
480
+
481
+ warning = "Reached maximum iterations without a final answer."
482
+ self.messages.append({"role": "assistant", "content": warning})
483
+ yield {"type": "content_delta", "delta": f"\n\n{warning}"}
484
+ yield {"type": "assistant_done", "content": warning}
pyagent/config.py ADDED
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from .user_runtime import (
8
+ DEFAULT_TOOL_RUNNER,
9
+ DEFAULT_USER_DIR,
10
+ TOOL_RUNNER_ENV_VAR,
11
+ USER_DIR_ENV_VAR,
12
+ resolve_user_dir,
13
+ )
14
+
15
+
16
+ def _parse_csv_env(value: str) -> tuple[str, ...]:
17
+ return tuple(item.strip() for item in value.split(",") if item.strip())
18
+
19
+
20
+ def _default_profiles_path() -> str:
21
+ return str(Path.home() / ".pyagent" / "models.json")
22
+
23
+
24
+ def _default_user_dir() -> str:
25
+ return str(resolve_user_dir())
26
+
27
+
28
+ def _default_system_prompt_path() -> str:
29
+ return str(Path.home() / ".pyagent" / "system_prompt.txt")
30
+
31
+
32
+ SYSTEM_PROMPT = """You are PyAgent, a capable coding assistant operating in a tool-use loop. You have access to tools to inspect the file system, read and write files, edit files, run shell commands, and any other tools that have been created to extend your toolset.
33
+
34
+ Prefer the tools that will get the job done, explain your reasoning briefly, and use tools when needed. Be precise when making code changes.
35
+
36
+ After you receive a tool result, continue the task and provide a complete user-facing answer; make sure that you fully answer user requests. If a tool directly answers the question, summarize the result and include the relevant output.
37
+ """
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class AppConfig:
42
+ request_timeout: int = 300
43
+ max_iterations: int = 10
44
+ max_history_messages: int = 24
45
+ stream_batch_interval: float = 0.05
46
+ default_profile: str | None = None
47
+ model_profiles_path: str = _default_profiles_path()
48
+ system_prompt_path: str = _default_system_prompt_path()
49
+ tools_enabled: bool = True
50
+ bash_enabled: bool = True
51
+ bash_readonly_mode: bool = False
52
+ bash_timeout_default: int = 60
53
+ bash_blocked_substrings: tuple[str, ...] = (
54
+ "sudo ",
55
+ "rm -",
56
+ "shutdown",
57
+ "reboot",
58
+ "mkfs",
59
+ " dd ",
60
+ "dd if=",
61
+ )
62
+ bash_readonly_prefixes: tuple[str, ...] = (
63
+ "pwd",
64
+ "ls",
65
+ "find",
66
+ "rg",
67
+ "grep",
68
+ "git status",
69
+ "git diff",
70
+ "git log",
71
+ "head",
72
+ "tail",
73
+ "wc",
74
+ "which",
75
+ )
76
+ user_dir: str = DEFAULT_USER_DIR
77
+ user_tools_enabled: bool = True
78
+ user_tool_timeout: float = 60.0
79
+ user_tool_describe_timeout: float = 10.0
80
+ tool_runner: str = DEFAULT_TOOL_RUNNER
81
+
82
+ @classmethod
83
+ def from_env(cls) -> "AppConfig":
84
+ defaults = cls()
85
+ return cls(
86
+ request_timeout=int(
87
+ os.getenv("PYAGENT_REQUEST_TIMEOUT",
88
+ str(defaults.request_timeout))
89
+ ),
90
+ max_iterations=int(
91
+ os.getenv("PYAGENT_MAX_ITERATIONS",
92
+ str(defaults.max_iterations))
93
+ ),
94
+ max_history_messages=int(
95
+ os.getenv(
96
+ "PYAGENT_MAX_HISTORY_MESSAGES",
97
+ str(defaults.max_history_messages),
98
+ )
99
+ ),
100
+ stream_batch_interval=float(
101
+ os.getenv(
102
+ "PYAGENT_STREAM_BATCH_INTERVAL",
103
+ str(defaults.stream_batch_interval),
104
+ )
105
+ ),
106
+ default_profile=os.getenv("PYAGENT_PROFILE") or None,
107
+ model_profiles_path=os.getenv(
108
+ "PYAGENT_MODEL_PROFILES_PATH",
109
+ defaults.model_profiles_path,
110
+ ),
111
+ system_prompt_path=os.getenv(
112
+ "PYAGENT_SYSTEM_PROMPT_PATH",
113
+ defaults.system_prompt_path,
114
+ ),
115
+ tools_enabled=os.getenv("PYAGENT_TOOLS_ENABLED", str(
116
+ defaults.tools_enabled)).lower()
117
+ in {"1", "true", "yes", "on"},
118
+ bash_enabled=os.getenv("PYAGENT_BASH_ENABLED", str(
119
+ defaults.bash_enabled)).lower()
120
+ in {"1", "true", "yes", "on"},
121
+ bash_readonly_mode=os.getenv(
122
+ "PYAGENT_BASH_READONLY_MODE", str(defaults.bash_readonly_mode)
123
+ ).lower()
124
+ in {"1", "true", "yes", "on"},
125
+ bash_timeout_default=int(
126
+ os.getenv(
127
+ "PYAGENT_BASH_TIMEOUT_DEFAULT",
128
+ str(defaults.bash_timeout_default),
129
+ )
130
+ ),
131
+ bash_blocked_substrings=_parse_csv_env(
132
+ os.getenv(
133
+ "PYAGENT_BASH_BLOCKED_SUBSTRINGS",
134
+ ",".join(defaults.bash_blocked_substrings),
135
+ )
136
+ ),
137
+ bash_readonly_prefixes=_parse_csv_env(
138
+ os.getenv(
139
+ "PYAGENT_BASH_READONLY_PREFIXES",
140
+ ",".join(defaults.bash_readonly_prefixes),
141
+ )
142
+ ),
143
+ user_dir=os.getenv(USER_DIR_ENV_VAR, defaults.user_dir),
144
+ user_tools_enabled=os.getenv(
145
+ "PYAGENT_USER_TOOLS_ENABLED",
146
+ str(defaults.user_tools_enabled),
147
+ ).lower()
148
+ in {"1", "true", "yes", "on"},
149
+ user_tool_timeout=float(
150
+ os.getenv(
151
+ "PYAGENT_USER_TOOL_TIMEOUT",
152
+ str(defaults.user_tool_timeout),
153
+ )
154
+ ),
155
+ user_tool_describe_timeout=float(
156
+ os.getenv(
157
+ "PYAGENT_USER_TOOL_DESCRIBE_TIMEOUT",
158
+ str(defaults.user_tool_describe_timeout),
159
+ )
160
+ ),
161
+ tool_runner=os.getenv(TOOL_RUNNER_ENV_VAR, defaults.tool_runner),
162
+ )