pygent 0.1.12__tar.gz → 0.1.14__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.
- pygent-0.1.14/PKG-INFO +132 -0
- {pygent-0.1.12 → pygent-0.1.14}/README.md +1 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/__init__.py +2 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/agent.py +32 -5
- {pygent-0.1.12 → pygent-0.1.14}/pygent/runtime.py +18 -0
- pygent-0.1.14/pygent/task_manager.py +125 -0
- pygent-0.1.14/pygent/tools.py +196 -0
- pygent-0.1.14/pygent.egg-info/PKG-INFO +132 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent.egg-info/SOURCES.txt +2 -0
- {pygent-0.1.12 → pygent-0.1.14}/pyproject.toml +2 -1
- pygent-0.1.14/tests/test_tasks.py +203 -0
- pygent-0.1.12/PKG-INFO +0 -20
- pygent-0.1.12/pygent/tools.py +0 -99
- pygent-0.1.12/pygent.egg-info/PKG-INFO +0 -20
- {pygent-0.1.12 → pygent-0.1.14}/LICENSE +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/__main__.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/cli.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/errors.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/models.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/openai_compat.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/py.typed +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent/ui.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent.egg-info/dependency_links.txt +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent.egg-info/entry_points.txt +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent.egg-info/requires.txt +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/pygent.egg-info/top_level.txt +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/setup.cfg +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_autorun.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_custom_model.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_error_handling.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_runtime.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_tools.py +0 -0
- {pygent-0.1.12 → pygent-0.1.14}/tests/test_version.py +0 -0
pygent-0.1.14/PKG-INFO
ADDED
@@ -0,0 +1,132 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pygent
|
3
|
+
Version: 0.1.14
|
4
|
+
Summary: Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code.
|
5
|
+
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
|
+
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
7
|
+
Project-URL: Repository, https://github.com/marianochaves/pygent
|
8
|
+
Requires-Python: >=3.9
|
9
|
+
Description-Content-Type: text/markdown
|
10
|
+
License-File: LICENSE
|
11
|
+
Requires-Dist: rich>=13.7.0
|
12
|
+
Requires-Dist: openai>=1.0.0
|
13
|
+
Provides-Extra: test
|
14
|
+
Requires-Dist: pytest; extra == "test"
|
15
|
+
Provides-Extra: docs
|
16
|
+
Requires-Dist: mkdocs; extra == "docs"
|
17
|
+
Provides-Extra: docker
|
18
|
+
Requires-Dist: docker>=7.0.0; extra == "docker"
|
19
|
+
Provides-Extra: ui
|
20
|
+
Requires-Dist: gradio; extra == "ui"
|
21
|
+
Dynamic: license-file
|
22
|
+
|
23
|
+
# Pygent
|
24
|
+
|
25
|
+
Pygent is a coding assistant that executes each request inside an isolated Docker container whenever possible. If Docker is unavailable (for instance on some Windows setups) the commands are executed locally instead. Full documentation is available in the `docs/` directory and at [marianochaves.github.io/pygent](https://marianochaves.github.io/pygent/).
|
26
|
+
|
27
|
+
## Features
|
28
|
+
|
29
|
+
* Runs commands in ephemeral containers (default image `python:3.12-slim`).
|
30
|
+
* Integrates with OpenAI-compatible models to orchestrate each step.
|
31
|
+
* Persists the conversation history during the session.
|
32
|
+
* Provides a small Python API for use in other projects.
|
33
|
+
* Optional web interface via `pygent-ui`.
|
34
|
+
* Register your own tools and customise the system prompt.
|
35
|
+
|
36
|
+
## Installation
|
37
|
+
|
38
|
+
Installing from source is recommended:
|
39
|
+
|
40
|
+
```bash
|
41
|
+
pip install -e .
|
42
|
+
```
|
43
|
+
|
44
|
+
Python ≥ 3.9 is required. The package now bundles the `openai` client for model access.
|
45
|
+
To run commands in Docker containers also install `pygent[docker]`.
|
46
|
+
|
47
|
+
## Configuration
|
48
|
+
|
49
|
+
Behaviour can be adjusted via environment variables (see `docs/configuration.md` for a complete list):
|
50
|
+
|
51
|
+
* `OPENAI_API_KEY` – key used to access the OpenAI API.
|
52
|
+
Set this to your API key or a key from any compatible provider.
|
53
|
+
* `OPENAI_BASE_URL` – base URL for OpenAI-compatible APIs
|
54
|
+
(defaults to ``https://api.openai.com/v1``).
|
55
|
+
* `PYGENT_MODEL` – model name used for requests (default `gpt-4.1-mini`).
|
56
|
+
* `PYGENT_IMAGE` – Docker image to create the container (default `python:3.12-slim`).
|
57
|
+
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
58
|
+
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
59
|
+
|
60
|
+
## CLI usage
|
61
|
+
|
62
|
+
After installing run:
|
63
|
+
|
64
|
+
```bash
|
65
|
+
pygent
|
66
|
+
```
|
67
|
+
|
68
|
+
Use `--docker` to run commands inside a container (requires
|
69
|
+
`pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
|
70
|
+
to force local execution.
|
71
|
+
|
72
|
+
Type messages normally; use `/exit` to end the session. Each command is executed
|
73
|
+
in the container and the result shown in the terminal.
|
74
|
+
Interactive programs that expect input (e.g. running `python` without a script)
|
75
|
+
are not supported and will exit immediately.
|
76
|
+
For a minimal web interface run `pygent-ui` instead (requires `pygent[ui]`).
|
77
|
+
|
78
|
+
|
79
|
+
## API usage
|
80
|
+
|
81
|
+
You can also interact directly with the Python code:
|
82
|
+
|
83
|
+
```python
|
84
|
+
from pygent import Agent
|
85
|
+
|
86
|
+
ag = Agent()
|
87
|
+
ag.step("echo 'Hello World'")
|
88
|
+
# ... more steps
|
89
|
+
ag.runtime.cleanup()
|
90
|
+
```
|
91
|
+
|
92
|
+
See the [examples](https://github.com/marianochaves/pygent/tree/main/examples) folder for more complete scripts. Models can be swapped by
|
93
|
+
passing an object implementing the ``Model`` interface when creating the
|
94
|
+
``Agent``. The default uses an OpenAI-compatible API, but custom models are
|
95
|
+
easy to plug in.
|
96
|
+
|
97
|
+
### Using OpenAI and other providers
|
98
|
+
|
99
|
+
Set your OpenAI key:
|
100
|
+
|
101
|
+
```bash
|
102
|
+
export OPENAI_API_KEY="sk-..."
|
103
|
+
```
|
104
|
+
|
105
|
+
To use a different provider, set `OPENAI_BASE_URL` to the provider
|
106
|
+
endpoint and keep `OPENAI_API_KEY` pointing to the correct key:
|
107
|
+
|
108
|
+
```bash
|
109
|
+
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
110
|
+
export OPENAI_API_KEY="your-provider-key"
|
111
|
+
```
|
112
|
+
|
113
|
+
## Development
|
114
|
+
|
115
|
+
1. Install the test dependencies:
|
116
|
+
|
117
|
+
```bash
|
118
|
+
pip install -e .[test]
|
119
|
+
```
|
120
|
+
|
121
|
+
2. Run the test suite:
|
122
|
+
|
123
|
+
```bash
|
124
|
+
pytest
|
125
|
+
```
|
126
|
+
|
127
|
+
Use `mkdocs serve` to build the documentation locally.
|
128
|
+
|
129
|
+
## License
|
130
|
+
|
131
|
+
This project is released under the MIT license. See the `LICENSE` file for details.
|
132
|
+
|
@@ -33,6 +33,7 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
|
|
33
33
|
* `PYGENT_MODEL` – model name used for requests (default `gpt-4.1-mini`).
|
34
34
|
* `PYGENT_IMAGE` – Docker image to create the container (default `python:3.12-slim`).
|
35
35
|
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
36
|
+
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
36
37
|
|
37
38
|
## CLI usage
|
38
39
|
|
@@ -10,6 +10,7 @@ from .agent import Agent, run_interactive # noqa: E402,F401, must come after __
|
|
10
10
|
from .models import Model, OpenAIModel # noqa: E402,F401
|
11
11
|
from .errors import PygentError, APIError # noqa: E402,F401
|
12
12
|
from .tools import register_tool, tool # noqa: E402,F401
|
13
|
+
from .task_manager import TaskManager # noqa: E402,F401
|
13
14
|
|
14
15
|
__all__ = [
|
15
16
|
"Agent",
|
@@ -20,4 +21,5 @@ __all__ = [
|
|
20
21
|
"APIError",
|
21
22
|
"register_tool",
|
22
23
|
"tool",
|
24
|
+
"TaskManager",
|
23
25
|
]
|
@@ -20,10 +20,10 @@ DEFAULT_MODEL = os.getenv("PYGENT_MODEL", "gpt-4.1-mini")
|
|
20
20
|
SYSTEM_MSG = (
|
21
21
|
"You are Pygent, a sandboxed coding assistant.\n"
|
22
22
|
"Respond with JSON when you need to use a tool."
|
23
|
-
"If you need to stop, call the `stop` tool.\n"
|
23
|
+
"If you need to stop or finished you task, call the `stop` tool.\n"
|
24
24
|
"You can use the following tools:\n"
|
25
25
|
f"{json.dumps(tools.TOOL_SCHEMAS, indent=2)}\n"
|
26
|
-
"You can also use the `continue` tool to continue the conversation.\n"
|
26
|
+
"You can also use the `continue` tool to request user input or continue the conversation.\n"
|
27
27
|
)
|
28
28
|
|
29
29
|
console = Console()
|
@@ -44,7 +44,10 @@ class Agent:
|
|
44
44
|
self.history.append({"role": "system", "content": self.system_msg})
|
45
45
|
|
46
46
|
def step(self, user_msg: str):
|
47
|
+
"""Execute one round of interaction with the model."""
|
48
|
+
|
47
49
|
self.history.append({"role": "user", "content": user_msg})
|
50
|
+
|
48
51
|
assistant_msg = self.model.chat(
|
49
52
|
self.history, self.model_name, tools.TOOL_SCHEMAS
|
50
53
|
)
|
@@ -60,12 +63,36 @@ class Agent:
|
|
60
63
|
console.print(Panel(markdown_response, title="Resposta do Agente", title_align="left", border_style="cyan"))
|
61
64
|
return assistant_msg
|
62
65
|
|
63
|
-
def run_until_stop(
|
64
|
-
|
65
|
-
|
66
|
+
def run_until_stop(
|
67
|
+
self,
|
68
|
+
user_msg: str,
|
69
|
+
max_steps: int = 20,
|
70
|
+
step_timeout: float | None = None,
|
71
|
+
max_time: float | None = None,
|
72
|
+
) -> None:
|
73
|
+
"""Run steps until ``stop`` is called or limits are reached."""
|
74
|
+
|
75
|
+
if step_timeout is None:
|
76
|
+
env = os.getenv("PYGENT_STEP_TIMEOUT")
|
77
|
+
step_timeout = float(env) if env else None
|
78
|
+
if max_time is None:
|
79
|
+
env = os.getenv("PYGENT_TASK_TIMEOUT")
|
80
|
+
max_time = float(env) if env else None
|
81
|
+
|
66
82
|
msg = user_msg
|
83
|
+
start = time.monotonic()
|
84
|
+
self._timed_out = False
|
67
85
|
for _ in range(max_steps):
|
86
|
+
if max_time is not None and time.monotonic() - start > max_time:
|
87
|
+
self.history.append({"role": "system", "content": f"[timeout after {max_time}s]"})
|
88
|
+
self._timed_out = True
|
89
|
+
break
|
90
|
+
step_start = time.monotonic()
|
68
91
|
assistant_msg = self.step(msg)
|
92
|
+
if step_timeout is not None and time.monotonic() - step_start > step_timeout:
|
93
|
+
self.history.append({"role": "system", "content": f"[timeout after {step_timeout}s]"})
|
94
|
+
self._timed_out = True
|
95
|
+
break
|
69
96
|
calls = assistant_msg.tool_calls or []
|
70
97
|
if any(c.function.name in ("stop", "continue") for c in calls):
|
71
98
|
break
|
@@ -92,6 +92,24 @@ class Runtime:
|
|
92
92
|
p.write_text(content, encoding="utf-8")
|
93
93
|
return f"Wrote {p.relative_to(self.base_dir)}"
|
94
94
|
|
95
|
+
def read_file(self, path: Union[str, Path], binary: bool = False) -> str:
|
96
|
+
"""Return the contents of a file relative to the workspace."""
|
97
|
+
|
98
|
+
p = self.base_dir / path
|
99
|
+
if not p.exists():
|
100
|
+
return f"file {p.relative_to(self.base_dir)} not found"
|
101
|
+
data = p.read_bytes()
|
102
|
+
if binary:
|
103
|
+
import base64
|
104
|
+
|
105
|
+
return base64.b64encode(data).decode()
|
106
|
+
try:
|
107
|
+
return data.decode()
|
108
|
+
except UnicodeDecodeError:
|
109
|
+
import base64
|
110
|
+
|
111
|
+
return base64.b64encode(data).decode()
|
112
|
+
|
95
113
|
def cleanup(self) -> None:
|
96
114
|
if self._use_docker and self.container is not None:
|
97
115
|
try:
|
@@ -0,0 +1,125 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
"""Manage background tasks executed by sub-agents."""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import shutil
|
7
|
+
import threading
|
8
|
+
import uuid
|
9
|
+
from dataclasses import dataclass, field
|
10
|
+
from typing import Callable, Dict, TYPE_CHECKING
|
11
|
+
|
12
|
+
from .runtime import Runtime
|
13
|
+
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - for type hints only
|
15
|
+
from .agent import Agent
|
16
|
+
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class Task:
|
20
|
+
"""Represents a delegated task."""
|
21
|
+
|
22
|
+
id: str
|
23
|
+
agent: "Agent"
|
24
|
+
thread: threading.Thread
|
25
|
+
status: str = field(default="running")
|
26
|
+
|
27
|
+
|
28
|
+
class TaskManager:
|
29
|
+
"""Launch agents asynchronously and track their progress."""
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
agent_factory: Callable[[], "Agent"] | None = None,
|
34
|
+
max_tasks: int | None = None,
|
35
|
+
) -> None:
|
36
|
+
from .agent import Agent # local import to avoid circular dependency
|
37
|
+
|
38
|
+
env_max = os.getenv("PYGENT_MAX_TASKS")
|
39
|
+
self.max_tasks = max_tasks if max_tasks is not None else int(env_max or "3")
|
40
|
+
self.agent_factory = agent_factory or Agent
|
41
|
+
self.tasks: Dict[str, Task] = {}
|
42
|
+
self._lock = threading.Lock()
|
43
|
+
|
44
|
+
def start_task(
|
45
|
+
self,
|
46
|
+
prompt: str,
|
47
|
+
parent_rt: Runtime,
|
48
|
+
files: list[str] | None = None,
|
49
|
+
parent_depth: int = 0,
|
50
|
+
step_timeout: float | None = None,
|
51
|
+
task_timeout: float | None = None,
|
52
|
+
) -> str:
|
53
|
+
"""Create a new agent and run ``prompt`` asynchronously."""
|
54
|
+
|
55
|
+
if parent_depth >= 1:
|
56
|
+
raise RuntimeError("nested delegation is not allowed")
|
57
|
+
|
58
|
+
with self._lock:
|
59
|
+
active = sum(t.status == "running" for t in self.tasks.values())
|
60
|
+
if active >= self.max_tasks:
|
61
|
+
raise RuntimeError(f"max {self.max_tasks} tasks reached")
|
62
|
+
|
63
|
+
if step_timeout is None:
|
64
|
+
env = os.getenv("PYGENT_STEP_TIMEOUT")
|
65
|
+
step_timeout = float(env) if env else 60*5 # default 5 minutes
|
66
|
+
if task_timeout is None:
|
67
|
+
env = os.getenv("PYGENT_TASK_TIMEOUT")
|
68
|
+
task_timeout = float(env) if env else 60*20 # default 20 minutes
|
69
|
+
|
70
|
+
agent = self.agent_factory()
|
71
|
+
setattr(agent.runtime, "task_depth", parent_depth + 1)
|
72
|
+
if files:
|
73
|
+
for fp in files:
|
74
|
+
src = parent_rt.base_dir / fp
|
75
|
+
dest = agent.runtime.base_dir / fp
|
76
|
+
if src.is_dir():
|
77
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
78
|
+
elif src.exists():
|
79
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
80
|
+
shutil.copy(src, dest)
|
81
|
+
task_id = uuid.uuid4().hex[:8]
|
82
|
+
task = Task(id=task_id, agent=agent, thread=None) # type: ignore[arg-type]
|
83
|
+
|
84
|
+
def run() -> None:
|
85
|
+
try:
|
86
|
+
agent.run_until_stop(
|
87
|
+
prompt,
|
88
|
+
step_timeout=step_timeout,
|
89
|
+
max_time=task_timeout,
|
90
|
+
)
|
91
|
+
if getattr(agent, "_timed_out", False):
|
92
|
+
task.status = f"timeout after {task_timeout}s"
|
93
|
+
else:
|
94
|
+
task.status = "finished"
|
95
|
+
except Exception as exc: # pragma: no cover - error propagation
|
96
|
+
task.status = f"error: {exc}"
|
97
|
+
|
98
|
+
t = threading.Thread(target=run, daemon=True)
|
99
|
+
task.thread = t
|
100
|
+
with self._lock:
|
101
|
+
self.tasks[task_id] = task
|
102
|
+
t.start()
|
103
|
+
return task_id
|
104
|
+
|
105
|
+
def status(self, task_id: str) -> str:
|
106
|
+
with self._lock:
|
107
|
+
task = self.tasks.get(task_id)
|
108
|
+
if not task:
|
109
|
+
return f"Task {task_id} not found"
|
110
|
+
return task.status
|
111
|
+
|
112
|
+
def collect_file(self, rt: Runtime, task_id: str, path: str) -> str:
|
113
|
+
"""Copy a file from a task workspace into ``rt``."""
|
114
|
+
|
115
|
+
with self._lock:
|
116
|
+
task = self.tasks.get(task_id)
|
117
|
+
if not task:
|
118
|
+
return f"Task {task_id} not found"
|
119
|
+
src = task.agent.runtime.base_dir / path
|
120
|
+
if not src.exists():
|
121
|
+
return f"file {path} not found"
|
122
|
+
dest = rt.base_dir / path
|
123
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
124
|
+
shutil.copy(src, dest)
|
125
|
+
return f"Retrieved {dest.relative_to(rt.base_dir)}"
|
@@ -0,0 +1,196 @@
|
|
1
|
+
"""Tool registry and helper utilities."""
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
import json
|
5
|
+
from typing import Any, Callable, Dict, List
|
6
|
+
|
7
|
+
from .runtime import Runtime
|
8
|
+
from .task_manager import TaskManager
|
9
|
+
|
10
|
+
_task_manager: TaskManager | None = None
|
11
|
+
|
12
|
+
|
13
|
+
def _get_manager() -> TaskManager:
|
14
|
+
global _task_manager
|
15
|
+
if _task_manager is None:
|
16
|
+
_task_manager = TaskManager()
|
17
|
+
return _task_manager
|
18
|
+
|
19
|
+
|
20
|
+
# ---- registry ----
|
21
|
+
TOOLS: Dict[str, Callable[..., str]] = {}
|
22
|
+
TOOL_SCHEMAS: List[Dict[str, Any]] = []
|
23
|
+
|
24
|
+
|
25
|
+
def register_tool(
|
26
|
+
name: str, description: str, parameters: Dict[str, Any], func: Callable[..., str]
|
27
|
+
) -> None:
|
28
|
+
"""Register a new callable tool."""
|
29
|
+
if name in TOOLS:
|
30
|
+
raise ValueError(f"tool {name} already registered")
|
31
|
+
TOOLS[name] = func
|
32
|
+
TOOL_SCHEMAS.append(
|
33
|
+
{
|
34
|
+
"type": "function",
|
35
|
+
"function": {
|
36
|
+
"name": name,
|
37
|
+
"description": description,
|
38
|
+
"parameters": parameters,
|
39
|
+
},
|
40
|
+
}
|
41
|
+
)
|
42
|
+
|
43
|
+
|
44
|
+
def tool(name: str, description: str, parameters: Dict[str, Any]):
|
45
|
+
"""Decorator for registering a tool."""
|
46
|
+
|
47
|
+
def decorator(func: Callable[..., str]) -> Callable[..., str]:
|
48
|
+
register_tool(name, description, parameters, func)
|
49
|
+
return func
|
50
|
+
|
51
|
+
return decorator
|
52
|
+
|
53
|
+
|
54
|
+
def execute_tool(call: Any, rt: Runtime) -> str: # pragma: no cover
|
55
|
+
"""Dispatch a tool call."""
|
56
|
+
name = call.function.name
|
57
|
+
args: Dict[str, Any] = json.loads(call.function.arguments)
|
58
|
+
func = TOOLS.get(name)
|
59
|
+
if func is None:
|
60
|
+
return f"⚠️ unknown tool {name}"
|
61
|
+
return func(rt, **args)
|
62
|
+
|
63
|
+
|
64
|
+
# ---- built-ins ----
|
65
|
+
|
66
|
+
|
67
|
+
@tool(
|
68
|
+
name="bash",
|
69
|
+
description="Run a shell command inside the sandboxed container.",
|
70
|
+
parameters={
|
71
|
+
"type": "object",
|
72
|
+
"properties": {"cmd": {"type": "string", "description": "Command to execute"}},
|
73
|
+
"required": ["cmd"],
|
74
|
+
},
|
75
|
+
)
|
76
|
+
def _bash(rt: Runtime, cmd: str) -> str:
|
77
|
+
return rt.bash(cmd)
|
78
|
+
|
79
|
+
|
80
|
+
@tool(
|
81
|
+
name="write_file",
|
82
|
+
description="Create or overwrite a file in the workspace.",
|
83
|
+
parameters={
|
84
|
+
"type": "object",
|
85
|
+
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
86
|
+
"required": ["path", "content"],
|
87
|
+
},
|
88
|
+
)
|
89
|
+
def _write_file(rt: Runtime, path: str, content: str) -> str:
|
90
|
+
return rt.write_file(path, content)
|
91
|
+
|
92
|
+
|
93
|
+
@tool(
|
94
|
+
name="stop",
|
95
|
+
description="Stop the autonomous loop. This is a side-effect free tool that does not return any output. Use when finished some task or when you want to stop the agent.",
|
96
|
+
parameters={"type": "object", "properties": {}},
|
97
|
+
)
|
98
|
+
def _stop(rt: Runtime) -> str: # pragma: no cover - side-effect free
|
99
|
+
return "Stopping."
|
100
|
+
|
101
|
+
|
102
|
+
@tool(
|
103
|
+
name="continue",
|
104
|
+
description="Request user answer or input. If in your previous message you asked for user input, you can use this tool to continue the conversation.",
|
105
|
+
parameters={"type": "object", "properties": {}},
|
106
|
+
)
|
107
|
+
def _continue(rt: Runtime) -> str: # pragma: no cover - side-effect free
|
108
|
+
return "Continuing the conversation."
|
109
|
+
|
110
|
+
|
111
|
+
|
112
|
+
|
113
|
+
@tool(
|
114
|
+
name="delegate_task",
|
115
|
+
description="Create a background task using a new agent and return its ID.",
|
116
|
+
parameters={
|
117
|
+
"type": "object",
|
118
|
+
"properties": {
|
119
|
+
"prompt": {"type": "string", "description": "Instruction for the sub-agent"},
|
120
|
+
"files": {
|
121
|
+
"type": "array",
|
122
|
+
"items": {"type": "string"},
|
123
|
+
"description": "Files to copy to the sub-agent before starting",
|
124
|
+
},
|
125
|
+
"timeout": {"type": "number", "description": "Max seconds for the task"},
|
126
|
+
"step_timeout": {"type": "number", "description": "Time limit per step"},
|
127
|
+
},
|
128
|
+
"required": ["prompt"],
|
129
|
+
},
|
130
|
+
)
|
131
|
+
def _delegate_task(
|
132
|
+
rt: Runtime,
|
133
|
+
prompt: str,
|
134
|
+
files: list[str] | None = None,
|
135
|
+
timeout: float | None = None,
|
136
|
+
step_timeout: float | None = None,
|
137
|
+
) -> str:
|
138
|
+
if getattr(rt, "task_depth", 0) >= 1:
|
139
|
+
return "error: delegation not allowed in sub-tasks"
|
140
|
+
try:
|
141
|
+
tid = _get_manager().start_task(
|
142
|
+
prompt,
|
143
|
+
parent_rt=rt,
|
144
|
+
files=files,
|
145
|
+
parent_depth=getattr(rt, "task_depth", 0),
|
146
|
+
step_timeout=step_timeout,
|
147
|
+
task_timeout=timeout,
|
148
|
+
)
|
149
|
+
except RuntimeError as exc:
|
150
|
+
return str(exc)
|
151
|
+
return f"started {tid}"
|
152
|
+
|
153
|
+
|
154
|
+
@tool(
|
155
|
+
name="task_status",
|
156
|
+
description="Check the status of a delegated task.",
|
157
|
+
parameters={
|
158
|
+
"type": "object",
|
159
|
+
"properties": {"task_id": {"type": "string"}},
|
160
|
+
"required": ["task_id"],
|
161
|
+
},
|
162
|
+
)
|
163
|
+
def _task_status(rt: Runtime, task_id: str) -> str:
|
164
|
+
return _get_manager().status(task_id)
|
165
|
+
|
166
|
+
|
167
|
+
@tool(
|
168
|
+
name="collect_file",
|
169
|
+
description="Retrieve a file from a delegated task into the main workspace.",
|
170
|
+
parameters={
|
171
|
+
"type": "object",
|
172
|
+
"properties": {
|
173
|
+
"task_id": {"type": "string"},
|
174
|
+
"path": {"type": "string"},
|
175
|
+
},
|
176
|
+
"required": ["task_id", "path"],
|
177
|
+
},
|
178
|
+
)
|
179
|
+
def _collect_file(rt: Runtime, task_id: str, path: str) -> str:
|
180
|
+
return _get_manager().collect_file(rt, task_id, path)
|
181
|
+
|
182
|
+
|
183
|
+
@tool(
|
184
|
+
name="download_file",
|
185
|
+
description="Return the contents of a file from the workspace (base64 if binary)",
|
186
|
+
parameters={
|
187
|
+
"type": "object",
|
188
|
+
"properties": {
|
189
|
+
"path": {"type": "string"},
|
190
|
+
"binary": {"type": "boolean", "default": False},
|
191
|
+
},
|
192
|
+
"required": ["path"],
|
193
|
+
},
|
194
|
+
)
|
195
|
+
def _download_file(rt: Runtime, path: str, binary: bool = False) -> str:
|
196
|
+
return rt.read_file(path, binary=binary)
|
@@ -0,0 +1,132 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: pygent
|
3
|
+
Version: 0.1.14
|
4
|
+
Summary: Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code.
|
5
|
+
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
|
+
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
7
|
+
Project-URL: Repository, https://github.com/marianochaves/pygent
|
8
|
+
Requires-Python: >=3.9
|
9
|
+
Description-Content-Type: text/markdown
|
10
|
+
License-File: LICENSE
|
11
|
+
Requires-Dist: rich>=13.7.0
|
12
|
+
Requires-Dist: openai>=1.0.0
|
13
|
+
Provides-Extra: test
|
14
|
+
Requires-Dist: pytest; extra == "test"
|
15
|
+
Provides-Extra: docs
|
16
|
+
Requires-Dist: mkdocs; extra == "docs"
|
17
|
+
Provides-Extra: docker
|
18
|
+
Requires-Dist: docker>=7.0.0; extra == "docker"
|
19
|
+
Provides-Extra: ui
|
20
|
+
Requires-Dist: gradio; extra == "ui"
|
21
|
+
Dynamic: license-file
|
22
|
+
|
23
|
+
# Pygent
|
24
|
+
|
25
|
+
Pygent is a coding assistant that executes each request inside an isolated Docker container whenever possible. If Docker is unavailable (for instance on some Windows setups) the commands are executed locally instead. Full documentation is available in the `docs/` directory and at [marianochaves.github.io/pygent](https://marianochaves.github.io/pygent/).
|
26
|
+
|
27
|
+
## Features
|
28
|
+
|
29
|
+
* Runs commands in ephemeral containers (default image `python:3.12-slim`).
|
30
|
+
* Integrates with OpenAI-compatible models to orchestrate each step.
|
31
|
+
* Persists the conversation history during the session.
|
32
|
+
* Provides a small Python API for use in other projects.
|
33
|
+
* Optional web interface via `pygent-ui`.
|
34
|
+
* Register your own tools and customise the system prompt.
|
35
|
+
|
36
|
+
## Installation
|
37
|
+
|
38
|
+
Installing from source is recommended:
|
39
|
+
|
40
|
+
```bash
|
41
|
+
pip install -e .
|
42
|
+
```
|
43
|
+
|
44
|
+
Python ≥ 3.9 is required. The package now bundles the `openai` client for model access.
|
45
|
+
To run commands in Docker containers also install `pygent[docker]`.
|
46
|
+
|
47
|
+
## Configuration
|
48
|
+
|
49
|
+
Behaviour can be adjusted via environment variables (see `docs/configuration.md` for a complete list):
|
50
|
+
|
51
|
+
* `OPENAI_API_KEY` – key used to access the OpenAI API.
|
52
|
+
Set this to your API key or a key from any compatible provider.
|
53
|
+
* `OPENAI_BASE_URL` – base URL for OpenAI-compatible APIs
|
54
|
+
(defaults to ``https://api.openai.com/v1``).
|
55
|
+
* `PYGENT_MODEL` – model name used for requests (default `gpt-4.1-mini`).
|
56
|
+
* `PYGENT_IMAGE` – Docker image to create the container (default `python:3.12-slim`).
|
57
|
+
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
58
|
+
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
59
|
+
|
60
|
+
## CLI usage
|
61
|
+
|
62
|
+
After installing run:
|
63
|
+
|
64
|
+
```bash
|
65
|
+
pygent
|
66
|
+
```
|
67
|
+
|
68
|
+
Use `--docker` to run commands inside a container (requires
|
69
|
+
`pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
|
70
|
+
to force local execution.
|
71
|
+
|
72
|
+
Type messages normally; use `/exit` to end the session. Each command is executed
|
73
|
+
in the container and the result shown in the terminal.
|
74
|
+
Interactive programs that expect input (e.g. running `python` without a script)
|
75
|
+
are not supported and will exit immediately.
|
76
|
+
For a minimal web interface run `pygent-ui` instead (requires `pygent[ui]`).
|
77
|
+
|
78
|
+
|
79
|
+
## API usage
|
80
|
+
|
81
|
+
You can also interact directly with the Python code:
|
82
|
+
|
83
|
+
```python
|
84
|
+
from pygent import Agent
|
85
|
+
|
86
|
+
ag = Agent()
|
87
|
+
ag.step("echo 'Hello World'")
|
88
|
+
# ... more steps
|
89
|
+
ag.runtime.cleanup()
|
90
|
+
```
|
91
|
+
|
92
|
+
See the [examples](https://github.com/marianochaves/pygent/tree/main/examples) folder for more complete scripts. Models can be swapped by
|
93
|
+
passing an object implementing the ``Model`` interface when creating the
|
94
|
+
``Agent``. The default uses an OpenAI-compatible API, but custom models are
|
95
|
+
easy to plug in.
|
96
|
+
|
97
|
+
### Using OpenAI and other providers
|
98
|
+
|
99
|
+
Set your OpenAI key:
|
100
|
+
|
101
|
+
```bash
|
102
|
+
export OPENAI_API_KEY="sk-..."
|
103
|
+
```
|
104
|
+
|
105
|
+
To use a different provider, set `OPENAI_BASE_URL` to the provider
|
106
|
+
endpoint and keep `OPENAI_API_KEY` pointing to the correct key:
|
107
|
+
|
108
|
+
```bash
|
109
|
+
export OPENAI_BASE_URL="https://openrouter.ai/api/v1"
|
110
|
+
export OPENAI_API_KEY="your-provider-key"
|
111
|
+
```
|
112
|
+
|
113
|
+
## Development
|
114
|
+
|
115
|
+
1. Install the test dependencies:
|
116
|
+
|
117
|
+
```bash
|
118
|
+
pip install -e .[test]
|
119
|
+
```
|
120
|
+
|
121
|
+
2. Run the test suite:
|
122
|
+
|
123
|
+
```bash
|
124
|
+
pytest
|
125
|
+
```
|
126
|
+
|
127
|
+
Use `mkdocs serve` to build the documentation locally.
|
128
|
+
|
129
|
+
## License
|
130
|
+
|
131
|
+
This project is released under the MIT license. See the `LICENSE` file for details.
|
132
|
+
|
@@ -10,6 +10,7 @@ pygent/models.py
|
|
10
10
|
pygent/openai_compat.py
|
11
11
|
pygent/py.typed
|
12
12
|
pygent/runtime.py
|
13
|
+
pygent/task_manager.py
|
13
14
|
pygent/tools.py
|
14
15
|
pygent/ui.py
|
15
16
|
pygent.egg-info/PKG-INFO
|
@@ -22,5 +23,6 @@ tests/test_autorun.py
|
|
22
23
|
tests/test_custom_model.py
|
23
24
|
tests/test_error_handling.py
|
24
25
|
tests/test_runtime.py
|
26
|
+
tests/test_tasks.py
|
25
27
|
tests/test_tools.py
|
26
28
|
tests/test_version.py
|
@@ -1,7 +1,8 @@
|
|
1
1
|
[project]
|
2
2
|
name = "pygent"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.14"
|
4
4
|
description = "Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code."
|
5
|
+
readme = "README.md"
|
5
6
|
authors = [ { name = "Mariano Chaves", email = "mchaves.software@gmail.com" } ]
|
6
7
|
requires-python = ">=3.9"
|
7
8
|
dependencies = [
|
@@ -0,0 +1,203 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import types
|
4
|
+
import time
|
5
|
+
|
6
|
+
sys.modules.setdefault('openai', types.ModuleType('openai'))
|
7
|
+
sys.modules.setdefault('docker', types.ModuleType('docker'))
|
8
|
+
|
9
|
+
# mocks for rich
|
10
|
+
rich_mod = types.ModuleType('rich')
|
11
|
+
console_mod = types.ModuleType('console')
|
12
|
+
panel_mod = types.ModuleType('panel')
|
13
|
+
markdown_mod = types.ModuleType('markdown')
|
14
|
+
syntax_mod = types.ModuleType('syntax')
|
15
|
+
console_mod.Console = lambda *a, **k: type('C', (), {'print': lambda *a, **k: None})()
|
16
|
+
panel_mod.Panel = lambda *a, **k: None
|
17
|
+
markdown_mod.Markdown = lambda *a, **k: None
|
18
|
+
syntax_mod.Syntax = lambda *a, **k: None
|
19
|
+
sys.modules.setdefault('rich', rich_mod)
|
20
|
+
sys.modules.setdefault('rich.console', console_mod)
|
21
|
+
sys.modules.setdefault('rich.panel', panel_mod)
|
22
|
+
sys.modules.setdefault('rich.markdown', markdown_mod)
|
23
|
+
sys.modules.setdefault('rich.syntax', syntax_mod)
|
24
|
+
|
25
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
26
|
+
|
27
|
+
from pygent import Agent
|
28
|
+
from pygent import openai_compat
|
29
|
+
from pygent.task_manager import TaskManager
|
30
|
+
from pygent.runtime import Runtime
|
31
|
+
from pygent import tools
|
32
|
+
|
33
|
+
class DummyModel:
|
34
|
+
def __init__(self):
|
35
|
+
self.count = 0
|
36
|
+
def chat(self, messages, model, tool_schemas):
|
37
|
+
self.count += 1
|
38
|
+
if self.count == 1:
|
39
|
+
return openai_compat.Message(
|
40
|
+
role='assistant',
|
41
|
+
content=None,
|
42
|
+
tool_calls=[
|
43
|
+
openai_compat.ToolCall(
|
44
|
+
id='1',
|
45
|
+
type='function',
|
46
|
+
function=openai_compat.ToolCallFunction(
|
47
|
+
name='write_file',
|
48
|
+
arguments='{"path": "foo.txt", "content": "bar"}'
|
49
|
+
)
|
50
|
+
)
|
51
|
+
]
|
52
|
+
)
|
53
|
+
else:
|
54
|
+
return openai_compat.Message(
|
55
|
+
role='assistant',
|
56
|
+
content=None,
|
57
|
+
tool_calls=[
|
58
|
+
openai_compat.ToolCall(
|
59
|
+
id='2',
|
60
|
+
type='function',
|
61
|
+
function=openai_compat.ToolCallFunction(
|
62
|
+
name='stop',
|
63
|
+
arguments='{}'
|
64
|
+
)
|
65
|
+
)
|
66
|
+
]
|
67
|
+
)
|
68
|
+
|
69
|
+
class SlowModel(DummyModel):
|
70
|
+
"""DummyModel variant that sleeps to simulate long-running tasks."""
|
71
|
+
|
72
|
+
def chat(self, messages, model, tool_schemas):
|
73
|
+
time.sleep(0.1)
|
74
|
+
return super().chat(messages, model, tool_schemas)
|
75
|
+
|
76
|
+
def make_agent():
|
77
|
+
return Agent(runtime=Runtime(use_docker=False), model=DummyModel())
|
78
|
+
|
79
|
+
def make_slow_agent():
|
80
|
+
return Agent(runtime=Runtime(use_docker=False), model=SlowModel())
|
81
|
+
|
82
|
+
def test_delegate_and_collect_file(tmp_path):
|
83
|
+
tm = TaskManager(agent_factory=make_agent)
|
84
|
+
tools._task_manager = tm
|
85
|
+
|
86
|
+
rt = Runtime(use_docker=False)
|
87
|
+
task_id = tools._delegate_task(rt, prompt='run')
|
88
|
+
tid = task_id.split()[-1]
|
89
|
+
tm.tasks[tid].thread.join()
|
90
|
+
|
91
|
+
status = tools._task_status(Runtime(use_docker=False), task_id=tid)
|
92
|
+
assert status == 'finished'
|
93
|
+
|
94
|
+
main_rt = Runtime(use_docker=False)
|
95
|
+
msg = tools._collect_file(main_rt, task_id=tid, path='foo.txt')
|
96
|
+
assert 'Retrieved' in msg
|
97
|
+
copied = main_rt.base_dir / 'foo.txt'
|
98
|
+
assert copied.exists() and copied.read_text() == 'bar'
|
99
|
+
main_rt.cleanup()
|
100
|
+
|
101
|
+
|
102
|
+
def test_delegate_with_files():
|
103
|
+
tm = TaskManager(agent_factory=make_agent)
|
104
|
+
tools._task_manager = tm
|
105
|
+
|
106
|
+
rt = Runtime(use_docker=False)
|
107
|
+
rt.write_file("data.txt", "hello")
|
108
|
+
tid_msg = tools._delegate_task(rt, prompt="run", files=["data.txt"])
|
109
|
+
tid = tid_msg.split()[-1]
|
110
|
+
tm.tasks[tid].thread.join()
|
111
|
+
|
112
|
+
child_path = tm.tasks[tid].agent.runtime.base_dir / "data.txt"
|
113
|
+
assert child_path.exists() and child_path.read_text() == "hello"
|
114
|
+
|
115
|
+
|
116
|
+
def test_download_file():
|
117
|
+
rt = Runtime(use_docker=False)
|
118
|
+
rt.write_file("sample.txt", "hi")
|
119
|
+
content = tools._download_file(rt, path="sample.txt")
|
120
|
+
assert content == "hi"
|
121
|
+
|
122
|
+
|
123
|
+
class DelegateModel:
|
124
|
+
def __init__(self):
|
125
|
+
self.count = 0
|
126
|
+
def chat(self, messages, model, tool_schemas):
|
127
|
+
self.count += 1
|
128
|
+
if self.count == 1:
|
129
|
+
return openai_compat.Message(
|
130
|
+
role='assistant',
|
131
|
+
content=None,
|
132
|
+
tool_calls=[
|
133
|
+
openai_compat.ToolCall(
|
134
|
+
id='1',
|
135
|
+
type='function',
|
136
|
+
function=openai_compat.ToolCallFunction(
|
137
|
+
name='delegate_task',
|
138
|
+
arguments='{"prompt": "noop"}'
|
139
|
+
)
|
140
|
+
)
|
141
|
+
]
|
142
|
+
)
|
143
|
+
else:
|
144
|
+
return openai_compat.Message(
|
145
|
+
role='assistant',
|
146
|
+
content=None,
|
147
|
+
tool_calls=[
|
148
|
+
openai_compat.ToolCall(
|
149
|
+
id='2',
|
150
|
+
type='function',
|
151
|
+
function=openai_compat.ToolCallFunction(
|
152
|
+
name='stop',
|
153
|
+
arguments='{}'
|
154
|
+
)
|
155
|
+
)
|
156
|
+
]
|
157
|
+
)
|
158
|
+
|
159
|
+
|
160
|
+
def make_delegate_agent():
|
161
|
+
return Agent(runtime=Runtime(use_docker=False), model=DelegateModel())
|
162
|
+
|
163
|
+
|
164
|
+
def test_no_nested_delegation():
|
165
|
+
tm = TaskManager(agent_factory=make_delegate_agent, max_tasks=2)
|
166
|
+
tools._task_manager = tm
|
167
|
+
|
168
|
+
tid_msg = tools._delegate_task(Runtime(use_docker=False), prompt='run')
|
169
|
+
tid = tid_msg.split()[-1]
|
170
|
+
tm.tasks[tid].thread.join()
|
171
|
+
|
172
|
+
# No new tasks should have been created inside the sub-agent
|
173
|
+
assert len(tm.tasks) == 1
|
174
|
+
|
175
|
+
|
176
|
+
def test_task_limit():
|
177
|
+
tm = TaskManager(agent_factory=make_slow_agent, max_tasks=1)
|
178
|
+
tools._task_manager = tm
|
179
|
+
|
180
|
+
first = tools._delegate_task(Runtime(use_docker=False), prompt='run')
|
181
|
+
assert first.startswith('started')
|
182
|
+
tid = first.split()[-1]
|
183
|
+
|
184
|
+
second = tools._delegate_task(Runtime(use_docker=False), prompt='run')
|
185
|
+
assert 'max' in second
|
186
|
+
|
187
|
+
tm.tasks[tid].thread.join()
|
188
|
+
|
189
|
+
|
190
|
+
def test_step_timeout():
|
191
|
+
ag = make_slow_agent()
|
192
|
+
ag.run_until_stop('run', step_timeout=0.05, max_steps=1)
|
193
|
+
assert 'timeout' in ag.history[-1]["content"]
|
194
|
+
|
195
|
+
|
196
|
+
def test_task_timeout():
|
197
|
+
tm = TaskManager(agent_factory=make_slow_agent, max_tasks=1)
|
198
|
+
tools._task_manager = tm
|
199
|
+
rt = Runtime(use_docker=False)
|
200
|
+
tid = tm.start_task('run', rt, task_timeout=0.05, step_timeout=0.01)
|
201
|
+
tm.tasks[tid].thread.join()
|
202
|
+
assert 'timeout' in tm.status(tid)
|
203
|
+
|
pygent-0.1.12/PKG-INFO
DELETED
@@ -1,20 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: pygent
|
3
|
-
Version: 0.1.12
|
4
|
-
Summary: Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code.
|
5
|
-
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
|
-
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
7
|
-
Project-URL: Repository, https://github.com/marianochaves/pygent
|
8
|
-
Requires-Python: >=3.9
|
9
|
-
License-File: LICENSE
|
10
|
-
Requires-Dist: rich>=13.7.0
|
11
|
-
Requires-Dist: openai>=1.0.0
|
12
|
-
Provides-Extra: test
|
13
|
-
Requires-Dist: pytest; extra == "test"
|
14
|
-
Provides-Extra: docs
|
15
|
-
Requires-Dist: mkdocs; extra == "docs"
|
16
|
-
Provides-Extra: docker
|
17
|
-
Requires-Dist: docker>=7.0.0; extra == "docker"
|
18
|
-
Provides-Extra: ui
|
19
|
-
Requires-Dist: gradio; extra == "ui"
|
20
|
-
Dynamic: license-file
|
pygent-0.1.12/pygent/tools.py
DELETED
@@ -1,99 +0,0 @@
|
|
1
|
-
"""Tool registry and helper utilities."""
|
2
|
-
from __future__ import annotations
|
3
|
-
|
4
|
-
import json
|
5
|
-
from typing import Any, Callable, Dict, List
|
6
|
-
|
7
|
-
from .runtime import Runtime
|
8
|
-
|
9
|
-
|
10
|
-
# ---- registry ----
|
11
|
-
TOOLS: Dict[str, Callable[..., str]] = {}
|
12
|
-
TOOL_SCHEMAS: List[Dict[str, Any]] = []
|
13
|
-
|
14
|
-
|
15
|
-
def register_tool(
|
16
|
-
name: str, description: str, parameters: Dict[str, Any], func: Callable[..., str]
|
17
|
-
) -> None:
|
18
|
-
"""Register a new callable tool."""
|
19
|
-
if name in TOOLS:
|
20
|
-
raise ValueError(f"tool {name} already registered")
|
21
|
-
TOOLS[name] = func
|
22
|
-
TOOL_SCHEMAS.append(
|
23
|
-
{
|
24
|
-
"type": "function",
|
25
|
-
"function": {
|
26
|
-
"name": name,
|
27
|
-
"description": description,
|
28
|
-
"parameters": parameters,
|
29
|
-
},
|
30
|
-
}
|
31
|
-
)
|
32
|
-
|
33
|
-
|
34
|
-
def tool(name: str, description: str, parameters: Dict[str, Any]):
|
35
|
-
"""Decorator for registering a tool."""
|
36
|
-
|
37
|
-
def decorator(func: Callable[..., str]) -> Callable[..., str]:
|
38
|
-
register_tool(name, description, parameters, func)
|
39
|
-
return func
|
40
|
-
|
41
|
-
return decorator
|
42
|
-
|
43
|
-
|
44
|
-
def execute_tool(call: Any, rt: Runtime) -> str: # pragma: no cover
|
45
|
-
"""Dispatch a tool call."""
|
46
|
-
name = call.function.name
|
47
|
-
args: Dict[str, Any] = json.loads(call.function.arguments)
|
48
|
-
func = TOOLS.get(name)
|
49
|
-
if func is None:
|
50
|
-
return f"⚠️ unknown tool {name}"
|
51
|
-
return func(rt, **args)
|
52
|
-
|
53
|
-
|
54
|
-
# ---- built-ins ----
|
55
|
-
|
56
|
-
|
57
|
-
@tool(
|
58
|
-
name="bash",
|
59
|
-
description="Run a shell command inside the sandboxed container.",
|
60
|
-
parameters={
|
61
|
-
"type": "object",
|
62
|
-
"properties": {"cmd": {"type": "string", "description": "Command to execute"}},
|
63
|
-
"required": ["cmd"],
|
64
|
-
},
|
65
|
-
)
|
66
|
-
def _bash(rt: Runtime, cmd: str) -> str:
|
67
|
-
return rt.bash(cmd)
|
68
|
-
|
69
|
-
|
70
|
-
@tool(
|
71
|
-
name="write_file",
|
72
|
-
description="Create or overwrite a file in the workspace.",
|
73
|
-
parameters={
|
74
|
-
"type": "object",
|
75
|
-
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
76
|
-
"required": ["path", "content"],
|
77
|
-
},
|
78
|
-
)
|
79
|
-
def _write_file(rt: Runtime, path: str, content: str) -> str:
|
80
|
-
return rt.write_file(path, content)
|
81
|
-
|
82
|
-
|
83
|
-
@tool(
|
84
|
-
name="stop",
|
85
|
-
description="Stop the autonomous loop.",
|
86
|
-
parameters={"type": "object", "properties": {}},
|
87
|
-
)
|
88
|
-
def _stop(rt: Runtime) -> str: # pragma: no cover - side-effect free
|
89
|
-
return "Stopping."
|
90
|
-
|
91
|
-
|
92
|
-
@tool(
|
93
|
-
name="continue",
|
94
|
-
description="Continue the conversation.",
|
95
|
-
parameters={"type": "object", "properties": {}},
|
96
|
-
)
|
97
|
-
def _continue(rt: Runtime) -> str: # pragma: no cover - side-effect free
|
98
|
-
return "Continuing the conversation."
|
99
|
-
|
@@ -1,20 +0,0 @@
|
|
1
|
-
Metadata-Version: 2.4
|
2
|
-
Name: pygent
|
3
|
-
Version: 0.1.12
|
4
|
-
Summary: Pygent is a minimalist coding assistant that runs commands in a Docker container when available and falls back to local execution. See https://marianochaves.github.io/pygent for documentation and https://github.com/marianochaves/pygent for the source code.
|
5
|
-
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
|
-
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
7
|
-
Project-URL: Repository, https://github.com/marianochaves/pygent
|
8
|
-
Requires-Python: >=3.9
|
9
|
-
License-File: LICENSE
|
10
|
-
Requires-Dist: rich>=13.7.0
|
11
|
-
Requires-Dist: openai>=1.0.0
|
12
|
-
Provides-Extra: test
|
13
|
-
Requires-Dist: pytest; extra == "test"
|
14
|
-
Provides-Extra: docs
|
15
|
-
Requires-Dist: mkdocs; extra == "docs"
|
16
|
-
Provides-Extra: docker
|
17
|
-
Requires-Dist: docker>=7.0.0; extra == "docker"
|
18
|
-
Provides-Extra: ui
|
19
|
-
Requires-Dist: gradio; extra == "ui"
|
20
|
-
Dynamic: license-file
|
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
|