pygent 0.1.11__tar.gz → 0.1.13__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.
Files changed (33) hide show
  1. pygent-0.1.13/PKG-INFO +132 -0
  2. {pygent-0.1.11 → pygent-0.1.13}/README.md +2 -0
  3. {pygent-0.1.11 → pygent-0.1.13}/pygent/__init__.py +13 -1
  4. {pygent-0.1.11 → pygent-0.1.13}/pygent/agent.py +14 -9
  5. {pygent-0.1.11 → pygent-0.1.13}/pygent/runtime.py +18 -0
  6. pygent-0.1.13/pygent/task_manager.py +109 -0
  7. pygent-0.1.13/pygent/tools.py +186 -0
  8. pygent-0.1.13/pygent.egg-info/PKG-INFO +132 -0
  9. {pygent-0.1.11 → pygent-0.1.13}/pygent.egg-info/SOURCES.txt +2 -0
  10. {pygent-0.1.11 → pygent-0.1.13}/pyproject.toml +2 -1
  11. pygent-0.1.13/tests/test_tasks.py +187 -0
  12. {pygent-0.1.11 → pygent-0.1.13}/tests/test_tools.py +22 -1
  13. pygent-0.1.11/PKG-INFO +0 -20
  14. pygent-0.1.11/pygent/tools.py +0 -70
  15. pygent-0.1.11/pygent.egg-info/PKG-INFO +0 -20
  16. {pygent-0.1.11 → pygent-0.1.13}/LICENSE +0 -0
  17. {pygent-0.1.11 → pygent-0.1.13}/pygent/__main__.py +0 -0
  18. {pygent-0.1.11 → pygent-0.1.13}/pygent/cli.py +0 -0
  19. {pygent-0.1.11 → pygent-0.1.13}/pygent/errors.py +0 -0
  20. {pygent-0.1.11 → pygent-0.1.13}/pygent/models.py +0 -0
  21. {pygent-0.1.11 → pygent-0.1.13}/pygent/openai_compat.py +0 -0
  22. {pygent-0.1.11 → pygent-0.1.13}/pygent/py.typed +0 -0
  23. {pygent-0.1.11 → pygent-0.1.13}/pygent/ui.py +0 -0
  24. {pygent-0.1.11 → pygent-0.1.13}/pygent.egg-info/dependency_links.txt +0 -0
  25. {pygent-0.1.11 → pygent-0.1.13}/pygent.egg-info/entry_points.txt +0 -0
  26. {pygent-0.1.11 → pygent-0.1.13}/pygent.egg-info/requires.txt +0 -0
  27. {pygent-0.1.11 → pygent-0.1.13}/pygent.egg-info/top_level.txt +0 -0
  28. {pygent-0.1.11 → pygent-0.1.13}/setup.cfg +0 -0
  29. {pygent-0.1.11 → pygent-0.1.13}/tests/test_autorun.py +0 -0
  30. {pygent-0.1.11 → pygent-0.1.13}/tests/test_custom_model.py +0 -0
  31. {pygent-0.1.11 → pygent-0.1.13}/tests/test_error_handling.py +0 -0
  32. {pygent-0.1.11 → pygent-0.1.13}/tests/test_runtime.py +0 -0
  33. {pygent-0.1.11 → pygent-0.1.13}/tests/test_version.py +0 -0
pygent-0.1.13/PKG-INFO ADDED
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygent
3
+ Version: 0.1.13
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` &ndash; 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` &ndash; base URL for OpenAI-compatible APIs
54
+ (defaults to ``https://api.openai.com/v1``).
55
+ * `PYGENT_MODEL` &ndash; model name used for requests (default `gpt-4.1-mini`).
56
+ * `PYGENT_IMAGE` &ndash; Docker image to create the container (default `python:3.12-slim`).
57
+ * `PYGENT_USE_DOCKER` &ndash; set to `0` to disable Docker and run locally.
58
+ * `PYGENT_MAX_TASKS` &ndash; 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
+
@@ -9,6 +9,7 @@ Pygent is a coding assistant that executes each request inside an isolated Docke
9
9
  * Persists the conversation history during the session.
10
10
  * Provides a small Python API for use in other projects.
11
11
  * Optional web interface via `pygent-ui`.
12
+ * Register your own tools and customise the system prompt.
12
13
 
13
14
  ## Installation
14
15
 
@@ -32,6 +33,7 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
32
33
  * `PYGENT_MODEL` &ndash; model name used for requests (default `gpt-4.1-mini`).
33
34
  * `PYGENT_IMAGE` &ndash; Docker image to create the container (default `python:3.12-slim`).
34
35
  * `PYGENT_USE_DOCKER` &ndash; set to `0` to disable Docker and run locally.
36
+ * `PYGENT_MAX_TASKS` &ndash; maximum number of concurrent delegated tasks (default `3`).
35
37
 
36
38
  ## CLI usage
37
39
 
@@ -9,5 +9,17 @@ except _metadata.PackageNotFoundError: # pragma: no cover - fallback for tests
9
9
  from .agent import Agent, run_interactive # noqa: E402,F401, must come after __version__
10
10
  from .models import Model, OpenAIModel # noqa: E402,F401
11
11
  from .errors import PygentError, APIError # noqa: E402,F401
12
+ from .tools import register_tool, tool # noqa: E402,F401
13
+ from .task_manager import TaskManager # noqa: E402,F401
12
14
 
13
- __all__ = ["Agent", "run_interactive", "Model", "OpenAIModel", "PygentError", "APIError"]
15
+ __all__ = [
16
+ "Agent",
17
+ "run_interactive",
18
+ "Model",
19
+ "OpenAIModel",
20
+ "PygentError",
21
+ "APIError",
22
+ "register_tool",
23
+ "tool",
24
+ "TaskManager",
25
+ ]
@@ -13,17 +13,17 @@ from rich.panel import Panel
13
13
  from rich.markdown import Markdown
14
14
 
15
15
  from .runtime import Runtime
16
- from .tools import TOOL_SCHEMAS, execute_tool
16
+ from . import tools
17
17
  from .models import Model, OpenAIModel
18
18
 
19
19
  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
- f"{json.dumps(TOOL_SCHEMAS, indent=2)}\n"
26
- "You can also use the `continue` tool to continue the conversation.\n"
25
+ f"{json.dumps(tools.TOOL_SCHEMAS, indent=2)}\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()
@@ -36,18 +36,23 @@ class Agent:
36
36
  runtime: Runtime = field(default_factory=Runtime)
37
37
  model: Model = field(default_factory=OpenAIModel)
38
38
  model_name: str = DEFAULT_MODEL
39
- history: List[Dict[str, Any]] = field(default_factory=lambda: [
40
- {"role": "system", "content": SYSTEM_MSG}
41
- ])
39
+ system_msg: str = SYSTEM_MSG
40
+ history: List[Dict[str, Any]] = field(default_factory=list)
41
+
42
+ def __post_init__(self) -> None:
43
+ if not self.history:
44
+ self.history.append({"role": "system", "content": self.system_msg})
42
45
 
43
46
  def step(self, user_msg: str):
44
47
  self.history.append({"role": "user", "content": user_msg})
45
- assistant_msg = self.model.chat(self.history, self.model_name, TOOL_SCHEMAS)
48
+ assistant_msg = self.model.chat(
49
+ self.history, self.model_name, tools.TOOL_SCHEMAS
50
+ )
46
51
  self.history.append(assistant_msg)
47
52
 
48
53
  if assistant_msg.tool_calls:
49
54
  for call in assistant_msg.tool_calls:
50
- output = execute_tool(call, self.runtime)
55
+ output = tools.execute_tool(call, self.runtime)
51
56
  self.history.append({"role": "tool", "content": output, "tool_call_id": call.id})
52
57
  console.print(Panel(output, title=f"tool:{call.function.name}"))
53
58
  else:
@@ -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,109 @@
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
+ ) -> str:
51
+ """Create a new agent and run ``prompt`` asynchronously."""
52
+
53
+ if parent_depth >= 1:
54
+ raise RuntimeError("nested delegation is not allowed")
55
+
56
+ with self._lock:
57
+ active = sum(t.status == "running" for t in self.tasks.values())
58
+ if active >= self.max_tasks:
59
+ raise RuntimeError(f"max {self.max_tasks} tasks reached")
60
+
61
+ agent = self.agent_factory()
62
+ setattr(agent.runtime, "task_depth", parent_depth + 1)
63
+ if files:
64
+ for fp in files:
65
+ src = parent_rt.base_dir / fp
66
+ dest = agent.runtime.base_dir / fp
67
+ if src.is_dir():
68
+ shutil.copytree(src, dest, dirs_exist_ok=True)
69
+ elif src.exists():
70
+ dest.parent.mkdir(parents=True, exist_ok=True)
71
+ shutil.copy(src, dest)
72
+ task_id = uuid.uuid4().hex[:8]
73
+ task = Task(id=task_id, agent=agent, thread=None) # type: ignore[arg-type]
74
+
75
+ def run() -> None:
76
+ try:
77
+ agent.run_until_stop(prompt)
78
+ task.status = "finished"
79
+ except Exception as exc: # pragma: no cover - error propagation
80
+ task.status = f"error: {exc}"
81
+
82
+ t = threading.Thread(target=run, daemon=True)
83
+ task.thread = t
84
+ with self._lock:
85
+ self.tasks[task_id] = task
86
+ t.start()
87
+ return task_id
88
+
89
+ def status(self, task_id: str) -> str:
90
+ with self._lock:
91
+ task = self.tasks.get(task_id)
92
+ if not task:
93
+ return f"Task {task_id} not found"
94
+ return task.status
95
+
96
+ def collect_file(self, rt: Runtime, task_id: str, path: str) -> str:
97
+ """Copy a file from a task workspace into ``rt``."""
98
+
99
+ with self._lock:
100
+ task = self.tasks.get(task_id)
101
+ if not task:
102
+ return f"Task {task_id} not found"
103
+ src = task.agent.runtime.base_dir / path
104
+ if not src.exists():
105
+ return f"file {path} not found"
106
+ dest = rt.base_dir / path
107
+ dest.parent.mkdir(parents=True, exist_ok=True)
108
+ shutil.copy(src, dest)
109
+ return f"Retrieved {dest.relative_to(rt.base_dir)}"
@@ -0,0 +1,186 @@
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
+ },
126
+ "required": ["prompt"],
127
+ },
128
+ )
129
+ def _delegate_task(rt: Runtime, prompt: str, files: list[str] | None = None) -> str:
130
+ if getattr(rt, "task_depth", 0) >= 1:
131
+ return "error: delegation not allowed in sub-tasks"
132
+ try:
133
+ tid = _get_manager().start_task(
134
+ prompt,
135
+ parent_rt=rt,
136
+ files=files,
137
+ parent_depth=getattr(rt, "task_depth", 0),
138
+ )
139
+ except RuntimeError as exc:
140
+ return str(exc)
141
+ return f"started {tid}"
142
+
143
+
144
+ @tool(
145
+ name="task_status",
146
+ description="Check the status of a delegated task.",
147
+ parameters={
148
+ "type": "object",
149
+ "properties": {"task_id": {"type": "string"}},
150
+ "required": ["task_id"],
151
+ },
152
+ )
153
+ def _task_status(rt: Runtime, task_id: str) -> str:
154
+ return _get_manager().status(task_id)
155
+
156
+
157
+ @tool(
158
+ name="collect_file",
159
+ description="Retrieve a file from a delegated task into the main workspace.",
160
+ parameters={
161
+ "type": "object",
162
+ "properties": {
163
+ "task_id": {"type": "string"},
164
+ "path": {"type": "string"},
165
+ },
166
+ "required": ["task_id", "path"],
167
+ },
168
+ )
169
+ def _collect_file(rt: Runtime, task_id: str, path: str) -> str:
170
+ return _get_manager().collect_file(rt, task_id, path)
171
+
172
+
173
+ @tool(
174
+ name="download_file",
175
+ description="Return the contents of a file from the workspace (base64 if binary)",
176
+ parameters={
177
+ "type": "object",
178
+ "properties": {
179
+ "path": {"type": "string"},
180
+ "binary": {"type": "boolean", "default": False},
181
+ },
182
+ "required": ["path"],
183
+ },
184
+ )
185
+ def _download_file(rt: Runtime, path: str, binary: bool = False) -> str:
186
+ return rt.read_file(path, binary=binary)
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: pygent
3
+ Version: 0.1.13
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` &ndash; 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` &ndash; base URL for OpenAI-compatible APIs
54
+ (defaults to ``https://api.openai.com/v1``).
55
+ * `PYGENT_MODEL` &ndash; model name used for requests (default `gpt-4.1-mini`).
56
+ * `PYGENT_IMAGE` &ndash; Docker image to create the container (default `python:3.12-slim`).
57
+ * `PYGENT_USE_DOCKER` &ndash; set to `0` to disable Docker and run locally.
58
+ * `PYGENT_MAX_TASKS` &ndash; 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.11"
3
+ version = "0.1.13"
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,187 @@
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()
@@ -26,7 +26,7 @@ sys.modules.setdefault('rich.syntax', syntax_mod) # Adicionado
26
26
 
27
27
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
28
28
 
29
- from pygent import tools
29
+ from pygent import tools, register_tool
30
30
 
31
31
  class DummyRuntime:
32
32
  def bash(self, cmd: str):
@@ -53,3 +53,24 @@ def test_execute_write_file():
53
53
  })()
54
54
  assert tools.execute_tool(call, DummyRuntime()) == 'wrote foo.txt'
55
55
 
56
+
57
+ def test_register_and_execute_custom_tool():
58
+ def hello(rt, name: str):
59
+ return f"hi {name}"
60
+
61
+ register_tool(
62
+ "hello",
63
+ "greet",
64
+ {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
65
+ hello,
66
+ )
67
+
68
+ call = type('Call', (), {
69
+ 'function': type('Func', (), {
70
+ 'name': 'hello',
71
+ 'arguments': '{"name": "bob"}'
72
+ })
73
+ })()
74
+ assert tools.execute_tool(call, DummyRuntime()) == 'hi bob'
75
+
76
+
pygent-0.1.11/PKG-INFO DELETED
@@ -1,20 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pygent
3
- Version: 0.1.11
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
@@ -1,70 +0,0 @@
1
- """Map of tools available to the agent."""
2
- from __future__ import annotations
3
- import json
4
- from typing import Any, Dict
5
-
6
- from .runtime import Runtime
7
-
8
- TOOL_SCHEMAS = [
9
- {
10
- "type": "function",
11
- "function": {
12
- "name": "bash",
13
- "description": "Run a shell command inside the sandboxed container.",
14
- "parameters": {
15
- "type": "object",
16
- "properties": {
17
- "cmd": {"type": "string", "description": "Command to execute"}
18
- },
19
- "required": ["cmd"],
20
- },
21
- },
22
- },
23
- {
24
- "type": "function",
25
- "function": {
26
- "name": "write_file",
27
- "description": "Create or overwrite a file in the workspace.",
28
- "parameters": {
29
- "type": "object",
30
- "properties": {
31
- "path": {"type": "string"},
32
- "content": {"type": "string"},
33
- },
34
- "required": ["path", "content"],
35
- },
36
- },
37
- },
38
- {
39
- "type": "function",
40
- "function": {
41
- "name": "stop",
42
- "description": "Stop the autonomous loop.",
43
- "parameters": {"type": "object", "properties": {}},
44
- },
45
- },
46
- {
47
- "type": "function",
48
- "function": {
49
- "name": "continue",
50
- "description": "Continue the conversation.",
51
- "parameters": {"type": "object", "properties": {}},
52
- },
53
- },
54
- ]
55
-
56
- # --------------- dispatcher ---------------
57
-
58
- def execute_tool(call: Any, rt: Runtime) -> str: # pragma: no cover, Any→openai.types.ToolCall
59
- name = call.function.name
60
- args: Dict[str, Any] = json.loads(call.function.arguments)
61
-
62
- if name == "bash":
63
- return rt.bash(**args)
64
- if name == "write_file":
65
- return rt.write_file(**args)
66
- if name == "stop":
67
- return "Stopping."
68
- if name == "continue":
69
- return "Continuing the conversation."
70
- return f"⚠️ unknown tool {name}"
@@ -1,20 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: pygent
3
- Version: 0.1.11
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