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 +1 -0
- pyagent/__main__.py +4 -0
- pyagent/agent.py +484 -0
- pyagent/config.py +162 -0
- pyagent/external_tools.py +521 -0
- pyagent/llm_client.py +408 -0
- pyagent/main.py +25 -0
- pyagent/model_profiles.py +210 -0
- pyagent/ollama_client.py +3 -0
- pyagent/project_context.py +235 -0
- pyagent/scaffold.py +65 -0
- pyagent/templates/__init__.py +5 -0
- pyagent/templates/tool_template.py +121 -0
- pyagent/tools.py +610 -0
- pyagent/ui.py +1320 -0
- pyagent/user_runtime.py +98 -0
- pyagent_harness-0.1.0.dist-info/METADATA +408 -0
- pyagent_harness-0.1.0.dist-info/RECORD +22 -0
- pyagent_harness-0.1.0.dist-info/WHEEL +5 -0
- pyagent_harness-0.1.0.dist-info/entry_points.txt +2 -0
- pyagent_harness-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyagent_harness-0.1.0.dist-info/top_level.txt +1 -0
pyagent/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__all__ = []
|
pyagent/__main__.py
ADDED
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
|
+
)
|