pygent 0.1.14__tar.gz → 0.1.16__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 → pygent-0.1.16}/PKG-INFO +8 -1
- {pygent-0.1.14 → pygent-0.1.16}/README.md +7 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/__init__.py +3 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/agent.py +21 -9
- {pygent-0.1.14 → pygent-0.1.16}/pygent/cli.py +5 -1
- pygent-0.1.16/pygent/config.py +57 -0
- pygent-0.1.16/pygent/persona.py +8 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/runtime.py +21 -1
- {pygent-0.1.14 → pygent-0.1.16}/pygent/task_manager.py +60 -7
- {pygent-0.1.14 → pygent-0.1.16}/pygent/tools.py +54 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/PKG-INFO +8 -1
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/SOURCES.txt +3 -0
- {pygent-0.1.14 → pygent-0.1.16}/pyproject.toml +1 -1
- pygent-0.1.16/tests/test_config.py +75 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_tasks.py +103 -57
- {pygent-0.1.14 → pygent-0.1.16}/LICENSE +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/__main__.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/errors.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/models.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/openai_compat.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/py.typed +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent/ui.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/dependency_links.txt +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/entry_points.txt +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/requires.txt +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/pygent.egg-info/top_level.txt +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/setup.cfg +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_autorun.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_custom_model.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_error_handling.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_runtime.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_tools.py +0 -0
- {pygent-0.1.14 → pygent-0.1.16}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pygent
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.16
|
4
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
5
|
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
6
|
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
@@ -57,6 +57,12 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
|
|
57
57
|
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
58
58
|
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
59
59
|
|
60
|
+
Settings can also be read from a `pygent.toml` file. See
|
61
|
+
[examples/sample_config.toml](https://github.com/marianochaves/pygent/blob/main/examples/sample_config.toml)
|
62
|
+
and the accompanying
|
63
|
+
[config_file_example.py](https://github.com/marianochaves/pygent/blob/main/examples/config_file_example.py)
|
64
|
+
script for a working demonstration that generates tests using a delegated agent.
|
65
|
+
|
60
66
|
## CLI usage
|
61
67
|
|
62
68
|
After installing run:
|
@@ -68,6 +74,7 @@ pygent
|
|
68
74
|
Use `--docker` to run commands inside a container (requires
|
69
75
|
`pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
|
70
76
|
to force local execution.
|
77
|
+
Pass `--config path/to/pygent.toml` to load settings from a file.
|
71
78
|
|
72
79
|
Type messages normally; use `/exit` to end the session. Each command is executed
|
73
80
|
in the container and the result shown in the terminal.
|
@@ -35,6 +35,12 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
|
|
35
35
|
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
36
36
|
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
37
37
|
|
38
|
+
Settings can also be read from a `pygent.toml` file. See
|
39
|
+
[examples/sample_config.toml](https://github.com/marianochaves/pygent/blob/main/examples/sample_config.toml)
|
40
|
+
and the accompanying
|
41
|
+
[config_file_example.py](https://github.com/marianochaves/pygent/blob/main/examples/config_file_example.py)
|
42
|
+
script for a working demonstration that generates tests using a delegated agent.
|
43
|
+
|
38
44
|
## CLI usage
|
39
45
|
|
40
46
|
After installing run:
|
@@ -46,6 +52,7 @@ pygent
|
|
46
52
|
Use `--docker` to run commands inside a container (requires
|
47
53
|
`pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
|
48
54
|
to force local execution.
|
55
|
+
Pass `--config path/to/pygent.toml` to load settings from a file.
|
49
56
|
|
50
57
|
Type messages normally; use `/exit` to end the session. Each command is executed
|
51
58
|
in the container and the result shown in the terminal.
|
@@ -1,6 +1,8 @@
|
|
1
1
|
"""Pygent package."""
|
2
2
|
from importlib import metadata as _metadata
|
3
3
|
|
4
|
+
from .config import load_config
|
5
|
+
|
4
6
|
try:
|
5
7
|
__version__: str = _metadata.version(__name__)
|
6
8
|
except _metadata.PackageNotFoundError: # pragma: no cover - fallback for tests
|
@@ -15,6 +17,7 @@ from .task_manager import TaskManager # noqa: E402,F401
|
|
15
17
|
__all__ = [
|
16
18
|
"Agent",
|
17
19
|
"run_interactive",
|
20
|
+
"load_config",
|
18
21
|
"Model",
|
19
22
|
"OpenAIModel",
|
20
23
|
"PygentError",
|
@@ -15,17 +15,26 @@ from rich.markdown import Markdown
|
|
15
15
|
from .runtime import Runtime
|
16
16
|
from . import tools
|
17
17
|
from .models import Model, OpenAIModel
|
18
|
+
from .persona import Persona
|
18
19
|
|
19
|
-
|
20
|
-
|
21
|
-
"
|
22
|
-
"Respond with JSON when you need to use a tool."
|
23
|
-
"If you need to stop or finished you task, call the `stop` tool.\n"
|
24
|
-
"You can use the following tools:\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"
|
20
|
+
DEFAULT_PERSONA = Persona(
|
21
|
+
os.getenv("PYGENT_PERSONA_NAME", "Pygent"),
|
22
|
+
os.getenv("PYGENT_PERSONA", "a sandboxed coding assistant."),
|
27
23
|
)
|
28
24
|
|
25
|
+
def build_system_msg(persona: Persona) -> str:
|
26
|
+
return (
|
27
|
+
f"You are {persona.name}. {persona.description}\n"
|
28
|
+
"Respond with JSON when you need to use a tool."
|
29
|
+
"If you need to stop or finished you task, call the `stop` tool.\n"
|
30
|
+
"You can use the following tools:\n"
|
31
|
+
f"{json.dumps(tools.TOOL_SCHEMAS, indent=2)}\n"
|
32
|
+
"You can also use the `continue` tool to request user input or continue the conversation.\n"
|
33
|
+
)
|
34
|
+
|
35
|
+
DEFAULT_MODEL = os.getenv("PYGENT_MODEL", "gpt-4.1-mini")
|
36
|
+
SYSTEM_MSG = build_system_msg(DEFAULT_PERSONA)
|
37
|
+
|
29
38
|
console = Console()
|
30
39
|
|
31
40
|
|
@@ -36,10 +45,13 @@ class Agent:
|
|
36
45
|
runtime: Runtime = field(default_factory=Runtime)
|
37
46
|
model: Model = field(default_factory=OpenAIModel)
|
38
47
|
model_name: str = DEFAULT_MODEL
|
39
|
-
|
48
|
+
persona: Persona = field(default_factory=lambda: DEFAULT_PERSONA)
|
49
|
+
system_msg: str = field(default_factory=lambda: build_system_msg(DEFAULT_PERSONA))
|
40
50
|
history: List[Dict[str, Any]] = field(default_factory=list)
|
41
51
|
|
42
52
|
def __post_init__(self) -> None:
|
53
|
+
if not self.system_msg:
|
54
|
+
self.system_msg = build_system_msg(self.persona)
|
43
55
|
if not self.history:
|
44
56
|
self.history.append({"role": "system", "content": self.system_msg})
|
45
57
|
|
@@ -1,12 +1,16 @@
|
|
1
1
|
"""Command-line entry point for Pygent."""
|
2
2
|
import argparse
|
3
3
|
|
4
|
-
from .
|
4
|
+
from .config import load_config
|
5
5
|
|
6
6
|
def main() -> None: # pragma: no cover
|
7
7
|
parser = argparse.ArgumentParser(prog="pygent")
|
8
8
|
parser.add_argument("--docker", dest="use_docker", action="store_true", help="run commands in a Docker container")
|
9
9
|
parser.add_argument("--no-docker", dest="use_docker", action="store_false", help="run locally")
|
10
|
+
parser.add_argument("-c", "--config", help="path to configuration file")
|
10
11
|
parser.set_defaults(use_docker=None)
|
11
12
|
args = parser.parse_args()
|
13
|
+
load_config(args.config)
|
14
|
+
from .agent import run_interactive
|
15
|
+
|
12
16
|
run_interactive(use_docker=args.use_docker)
|
@@ -0,0 +1,57 @@
|
|
1
|
+
import os
|
2
|
+
import json
|
3
|
+
import tomllib
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Any, Dict, List, Mapping
|
6
|
+
|
7
|
+
DEFAULT_CONFIG_FILES = [
|
8
|
+
Path("pygent.toml"),
|
9
|
+
Path.home() / ".pygent.toml",
|
10
|
+
]
|
11
|
+
|
12
|
+
|
13
|
+
def load_config(path: str | os.PathLike[str] | None = None) -> Dict[str, Any]:
|
14
|
+
"""Load configuration from a TOML file and set environment variables.
|
15
|
+
|
16
|
+
Environment variables already set take precedence over file values.
|
17
|
+
Returns the configuration dictionary.
|
18
|
+
"""
|
19
|
+
config: Dict[str, Any] = {}
|
20
|
+
paths = [Path(path)] if path else DEFAULT_CONFIG_FILES
|
21
|
+
for p in paths:
|
22
|
+
if p.is_file():
|
23
|
+
with p.open("rb") as fh:
|
24
|
+
try:
|
25
|
+
data = tomllib.load(fh)
|
26
|
+
except Exception:
|
27
|
+
continue
|
28
|
+
config.update(data)
|
29
|
+
# update environment without overwriting existing values
|
30
|
+
if "persona" in config and "PYGENT_PERSONA" not in os.environ:
|
31
|
+
os.environ["PYGENT_PERSONA"] = str(config["persona"])
|
32
|
+
if "persona_name" in config and "PYGENT_PERSONA_NAME" not in os.environ:
|
33
|
+
os.environ["PYGENT_PERSONA_NAME"] = str(config["persona_name"])
|
34
|
+
if "task_personas" in config:
|
35
|
+
personas = config["task_personas"]
|
36
|
+
if isinstance(personas, list) and personas and isinstance(personas[0], Mapping):
|
37
|
+
if "PYGENT_TASK_PERSONAS_JSON" not in os.environ:
|
38
|
+
os.environ["PYGENT_TASK_PERSONAS_JSON"] = json.dumps(personas)
|
39
|
+
if "PYGENT_TASK_PERSONAS" not in os.environ:
|
40
|
+
os.environ["PYGENT_TASK_PERSONAS"] = os.pathsep.join(
|
41
|
+
str(p.get("name", "")) for p in personas
|
42
|
+
)
|
43
|
+
elif "PYGENT_TASK_PERSONAS" not in os.environ:
|
44
|
+
if isinstance(personas, list):
|
45
|
+
os.environ["PYGENT_TASK_PERSONAS"] = os.pathsep.join(
|
46
|
+
str(p) for p in personas
|
47
|
+
)
|
48
|
+
else:
|
49
|
+
os.environ["PYGENT_TASK_PERSONAS"] = str(personas)
|
50
|
+
if "initial_files" in config and "PYGENT_INIT_FILES" not in os.environ:
|
51
|
+
if isinstance(config["initial_files"], list):
|
52
|
+
os.environ["PYGENT_INIT_FILES"] = os.pathsep.join(
|
53
|
+
str(p) for p in config["initial_files"]
|
54
|
+
)
|
55
|
+
else:
|
56
|
+
os.environ["PYGENT_INIT_FILES"] = str(config["initial_files"])
|
57
|
+
return config
|
@@ -18,8 +18,18 @@ except Exception: # pragma: no cover - optional dependency
|
|
18
18
|
class Runtime:
|
19
19
|
"""Executes commands in a Docker container or locally if Docker is unavailable."""
|
20
20
|
|
21
|
-
def __init__(
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
image: str | None = None,
|
24
|
+
use_docker: bool | None = None,
|
25
|
+
initial_files: list[str] | None = None,
|
26
|
+
) -> None:
|
22
27
|
self.base_dir = Path(tempfile.mkdtemp(prefix="pygent_"))
|
28
|
+
if initial_files is None:
|
29
|
+
env_files = os.getenv("PYGENT_INIT_FILES")
|
30
|
+
if env_files:
|
31
|
+
initial_files = [f.strip() for f in env_files.split(os.pathsep) if f.strip()]
|
32
|
+
self._initial_files = initial_files or []
|
23
33
|
self.image = image or os.getenv("PYGENT_IMAGE", "python:3.12-slim")
|
24
34
|
env_opt = os.getenv("PYGENT_USE_DOCKER")
|
25
35
|
if use_docker is None:
|
@@ -46,6 +56,16 @@ class Runtime:
|
|
46
56
|
self.client = None
|
47
57
|
self.container = None
|
48
58
|
|
59
|
+
# populate workspace with initial files
|
60
|
+
for fp in self._initial_files:
|
61
|
+
src = Path(fp).expanduser()
|
62
|
+
dest = self.base_dir / src.name
|
63
|
+
if src.is_dir():
|
64
|
+
shutil.copytree(src, dest, dirs_exist_ok=True)
|
65
|
+
elif src.exists():
|
66
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
67
|
+
shutil.copy(src, dest)
|
68
|
+
|
49
69
|
# ---------------- public API ----------------
|
50
70
|
def bash(self, cmd: str, timeout: int = 30) -> str:
|
51
71
|
"""Run a command in the container or locally and return the output.
|
@@ -3,12 +3,15 @@ from __future__ import annotations
|
|
3
3
|
"""Manage background tasks executed by sub-agents."""
|
4
4
|
|
5
5
|
import os
|
6
|
+
import json
|
6
7
|
import shutil
|
7
8
|
import threading
|
8
9
|
import uuid
|
9
10
|
from dataclasses import dataclass, field
|
10
11
|
from typing import Callable, Dict, TYPE_CHECKING
|
11
12
|
|
13
|
+
from .persona import Persona
|
14
|
+
|
12
15
|
from .runtime import Runtime
|
13
16
|
|
14
17
|
if TYPE_CHECKING: # pragma: no cover - for type hints only
|
@@ -30,14 +33,46 @@ class TaskManager:
|
|
30
33
|
|
31
34
|
def __init__(
|
32
35
|
self,
|
33
|
-
agent_factory: Callable[
|
36
|
+
agent_factory: Callable[..., "Agent"] | None = None,
|
34
37
|
max_tasks: int | None = None,
|
38
|
+
personas: list[Persona] | None = None,
|
35
39
|
) -> None:
|
36
40
|
from .agent import Agent # local import to avoid circular dependency
|
37
41
|
|
38
42
|
env_max = os.getenv("PYGENT_MAX_TASKS")
|
39
43
|
self.max_tasks = max_tasks if max_tasks is not None else int(env_max or "3")
|
40
|
-
|
44
|
+
if agent_factory is None:
|
45
|
+
self.agent_factory = lambda p=None: Agent(persona=p)
|
46
|
+
else:
|
47
|
+
self.agent_factory = agent_factory
|
48
|
+
env_personas_json = os.getenv("PYGENT_TASK_PERSONAS_JSON")
|
49
|
+
if personas is None and env_personas_json:
|
50
|
+
try:
|
51
|
+
data = json.loads(env_personas_json)
|
52
|
+
if isinstance(data, list):
|
53
|
+
personas = [
|
54
|
+
Persona(p.get("name", ""), p.get("description", ""))
|
55
|
+
for p in data
|
56
|
+
if isinstance(p, dict)
|
57
|
+
]
|
58
|
+
except Exception:
|
59
|
+
personas = None
|
60
|
+
env_personas = os.getenv("PYGENT_TASK_PERSONAS")
|
61
|
+
if personas is None and env_personas:
|
62
|
+
personas = [
|
63
|
+
Persona(p.strip(), "")
|
64
|
+
for p in env_personas.split(os.pathsep)
|
65
|
+
if p.strip()
|
66
|
+
]
|
67
|
+
if personas is None:
|
68
|
+
personas = [
|
69
|
+
Persona(
|
70
|
+
os.getenv("PYGENT_PERSONA_NAME", "Pygent"),
|
71
|
+
os.getenv("PYGENT_PERSONA", "a sandboxed coding assistant."),
|
72
|
+
)
|
73
|
+
]
|
74
|
+
self.personas = personas
|
75
|
+
self._persona_idx = 0
|
41
76
|
self.tasks: Dict[str, Task] = {}
|
42
77
|
self._lock = threading.Lock()
|
43
78
|
|
@@ -49,8 +84,12 @@ class TaskManager:
|
|
49
84
|
parent_depth: int = 0,
|
50
85
|
step_timeout: float | None = None,
|
51
86
|
task_timeout: float | None = None,
|
87
|
+
persona: Persona | str | None = None,
|
52
88
|
) -> str:
|
53
|
-
"""Create a new agent and run ``prompt`` asynchronously.
|
89
|
+
"""Create a new agent and run ``prompt`` asynchronously.
|
90
|
+
|
91
|
+
``persona`` overrides the default rotation used for delegated tasks.
|
92
|
+
"""
|
54
93
|
|
55
94
|
if parent_depth >= 1:
|
56
95
|
raise RuntimeError("nested delegation is not allowed")
|
@@ -62,12 +101,26 @@ class TaskManager:
|
|
62
101
|
|
63
102
|
if step_timeout is None:
|
64
103
|
env = os.getenv("PYGENT_STEP_TIMEOUT")
|
65
|
-
step_timeout = float(env) if env else 60*5
|
104
|
+
step_timeout = float(env) if env else 60 * 5 # default 5 minutes
|
66
105
|
if task_timeout is None:
|
67
106
|
env = os.getenv("PYGENT_TASK_TIMEOUT")
|
68
|
-
task_timeout = float(env) if env else 60*20
|
69
|
-
|
70
|
-
|
107
|
+
task_timeout = float(env) if env else 60 * 20 # default 20 minutes
|
108
|
+
|
109
|
+
if persona is None:
|
110
|
+
persona = self.personas[self._persona_idx % len(self.personas)]
|
111
|
+
self._persona_idx += 1
|
112
|
+
elif isinstance(persona, str):
|
113
|
+
match = next((p for p in self.personas if p.name == persona), None)
|
114
|
+
persona = match or Persona(persona, "")
|
115
|
+
try:
|
116
|
+
agent = self.agent_factory(persona)
|
117
|
+
except TypeError:
|
118
|
+
agent = self.agent_factory()
|
119
|
+
setattr(agent, "persona", persona)
|
120
|
+
if not getattr(agent, "system_msg", None):
|
121
|
+
from .agent import build_system_msg # lazy import
|
122
|
+
|
123
|
+
agent.system_msg = build_system_msg(persona)
|
71
124
|
setattr(agent.runtime, "task_depth", parent_depth + 1)
|
72
125
|
if files:
|
73
126
|
for fp in files:
|
@@ -122,6 +122,7 @@ def _continue(rt: Runtime) -> str: # pragma: no cover - side-effect free
|
|
122
122
|
"items": {"type": "string"},
|
123
123
|
"description": "Files to copy to the sub-agent before starting",
|
124
124
|
},
|
125
|
+
"persona": {"type": "string", "description": "Persona for the sub-agent"},
|
125
126
|
"timeout": {"type": "number", "description": "Max seconds for the task"},
|
126
127
|
"step_timeout": {"type": "number", "description": "Time limit per step"},
|
127
128
|
},
|
@@ -134,6 +135,7 @@ def _delegate_task(
|
|
134
135
|
files: list[str] | None = None,
|
135
136
|
timeout: float | None = None,
|
136
137
|
step_timeout: float | None = None,
|
138
|
+
persona: str | None = None,
|
137
139
|
) -> str:
|
138
140
|
if getattr(rt, "task_depth", 0) >= 1:
|
139
141
|
return "error: delegation not allowed in sub-tasks"
|
@@ -145,12 +147,64 @@ def _delegate_task(
|
|
145
147
|
parent_depth=getattr(rt, "task_depth", 0),
|
146
148
|
step_timeout=step_timeout,
|
147
149
|
task_timeout=timeout,
|
150
|
+
persona=persona,
|
148
151
|
)
|
149
152
|
except RuntimeError as exc:
|
150
153
|
return str(exc)
|
151
154
|
return f"started {tid}"
|
152
155
|
|
153
156
|
|
157
|
+
@tool(
|
158
|
+
name="delegate_persona_task",
|
159
|
+
description="Create a background task with a specific persona and return its ID.",
|
160
|
+
parameters={
|
161
|
+
"type": "object",
|
162
|
+
"properties": {
|
163
|
+
"prompt": {"type": "string", "description": "Instruction for the sub-agent"},
|
164
|
+
"persona": {"type": "string", "description": "Persona for the sub-agent"},
|
165
|
+
"files": {
|
166
|
+
"type": "array",
|
167
|
+
"items": {"type": "string"},
|
168
|
+
"description": "Files to copy to the sub-agent before starting",
|
169
|
+
},
|
170
|
+
"timeout": {"type": "number", "description": "Max seconds for the task"},
|
171
|
+
"step_timeout": {"type": "number", "description": "Time limit per step"},
|
172
|
+
},
|
173
|
+
"required": ["prompt", "persona"],
|
174
|
+
},
|
175
|
+
)
|
176
|
+
def _delegate_persona_task(
|
177
|
+
rt: Runtime,
|
178
|
+
prompt: str,
|
179
|
+
persona: str,
|
180
|
+
files: list[str] | None = None,
|
181
|
+
timeout: float | None = None,
|
182
|
+
step_timeout: float | None = None,
|
183
|
+
) -> str:
|
184
|
+
return _delegate_task(
|
185
|
+
rt,
|
186
|
+
prompt=prompt,
|
187
|
+
files=files,
|
188
|
+
timeout=timeout,
|
189
|
+
step_timeout=step_timeout,
|
190
|
+
persona=persona,
|
191
|
+
)
|
192
|
+
|
193
|
+
|
194
|
+
@tool(
|
195
|
+
name="list_personas",
|
196
|
+
description="Return the available personas for delegated agents.",
|
197
|
+
parameters={"type": "object", "properties": {}},
|
198
|
+
)
|
199
|
+
def _list_personas(rt: Runtime) -> str:
|
200
|
+
"""Return JSON list of personas."""
|
201
|
+
personas = [
|
202
|
+
{"name": p.name, "description": p.description}
|
203
|
+
for p in _get_manager().personas
|
204
|
+
]
|
205
|
+
return json.dumps(personas)
|
206
|
+
|
207
|
+
|
154
208
|
@tool(
|
155
209
|
name="task_status",
|
156
210
|
description="Check the status of a delegated task.",
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: pygent
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.16
|
4
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
5
|
Author-email: Mariano Chaves <mchaves.software@gmail.com>
|
6
6
|
Project-URL: Documentation, https://marianochaves.github.io/pygent
|
@@ -57,6 +57,12 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
|
|
57
57
|
* `PYGENT_USE_DOCKER` – set to `0` to disable Docker and run locally.
|
58
58
|
* `PYGENT_MAX_TASKS` – maximum number of concurrent delegated tasks (default `3`).
|
59
59
|
|
60
|
+
Settings can also be read from a `pygent.toml` file. See
|
61
|
+
[examples/sample_config.toml](https://github.com/marianochaves/pygent/blob/main/examples/sample_config.toml)
|
62
|
+
and the accompanying
|
63
|
+
[config_file_example.py](https://github.com/marianochaves/pygent/blob/main/examples/config_file_example.py)
|
64
|
+
script for a working demonstration that generates tests using a delegated agent.
|
65
|
+
|
60
66
|
## CLI usage
|
61
67
|
|
62
68
|
After installing run:
|
@@ -68,6 +74,7 @@ pygent
|
|
68
74
|
Use `--docker` to run commands inside a container (requires
|
69
75
|
`pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
|
70
76
|
to force local execution.
|
77
|
+
Pass `--config path/to/pygent.toml` to load settings from a file.
|
71
78
|
|
72
79
|
Type messages normally; use `/exit` to end the session. Each command is executed
|
73
80
|
in the container and the result shown in the terminal.
|
@@ -5,9 +5,11 @@ pygent/__init__.py
|
|
5
5
|
pygent/__main__.py
|
6
6
|
pygent/agent.py
|
7
7
|
pygent/cli.py
|
8
|
+
pygent/config.py
|
8
9
|
pygent/errors.py
|
9
10
|
pygent/models.py
|
10
11
|
pygent/openai_compat.py
|
12
|
+
pygent/persona.py
|
11
13
|
pygent/py.typed
|
12
14
|
pygent/runtime.py
|
13
15
|
pygent/task_manager.py
|
@@ -20,6 +22,7 @@ pygent.egg-info/entry_points.txt
|
|
20
22
|
pygent.egg-info/requires.txt
|
21
23
|
pygent.egg-info/top_level.txt
|
22
24
|
tests/test_autorun.py
|
25
|
+
tests/test_config.py
|
23
26
|
tests/test_custom_model.py
|
24
27
|
tests/test_error_handling.py
|
25
28
|
tests/test_runtime.py
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "pygent"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.16"
|
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
5
|
readme = "README.md"
|
6
6
|
authors = [ { name = "Mariano Chaves", email = "mchaves.software@gmail.com" } ]
|
@@ -0,0 +1,75 @@
|
|
1
|
+
import os
|
2
|
+
import sys
|
3
|
+
import types
|
4
|
+
import json
|
5
|
+
|
6
|
+
sys.modules.setdefault("openai", types.ModuleType("openai"))
|
7
|
+
sys.modules.setdefault("docker", types.ModuleType("docker"))
|
8
|
+
|
9
|
+
# minimal 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
|
+
console_mod.Console = lambda *a, **k: None
|
15
|
+
panel_mod.Panel = lambda *a, **k: None
|
16
|
+
markdown_mod.Markdown = lambda *a, **k: None
|
17
|
+
sys.modules.setdefault("rich", rich_mod)
|
18
|
+
sys.modules.setdefault("rich.console", console_mod)
|
19
|
+
sys.modules.setdefault("rich.panel", panel_mod)
|
20
|
+
sys.modules.setdefault("rich.markdown", markdown_mod)
|
21
|
+
|
22
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
23
|
+
|
24
|
+
from pygent.config import load_config
|
25
|
+
from pygent.runtime import Runtime
|
26
|
+
from pygent.task_manager import TaskManager
|
27
|
+
from pygent.persona import Persona
|
28
|
+
|
29
|
+
|
30
|
+
def test_load_config(tmp_path, monkeypatch):
|
31
|
+
cfg = tmp_path / "pygent.toml"
|
32
|
+
cfg.write_text(
|
33
|
+
'persona="bot"\n'
|
34
|
+
'persona_name="Bot"\n'
|
35
|
+
'initial_files=["seed.txt"]\n'
|
36
|
+
'[[task_personas]]\nname="a"\ndescription="desc a"\n'
|
37
|
+
'[[task_personas]]\nname="b"\ndescription="desc b"\n'
|
38
|
+
)
|
39
|
+
(tmp_path / "seed.txt").write_text("seed")
|
40
|
+
monkeypatch.chdir(tmp_path)
|
41
|
+
monkeypatch.delenv("PYGENT_PERSONA", raising=False)
|
42
|
+
monkeypatch.delenv("PYGENT_TASK_PERSONAS", raising=False)
|
43
|
+
monkeypatch.delenv("PYGENT_TASK_PERSONAS_JSON", raising=False)
|
44
|
+
monkeypatch.delenv("PYGENT_INIT_FILES", raising=False)
|
45
|
+
monkeypatch.delenv("PYGENT_PERSONA_NAME", raising=False)
|
46
|
+
load_config()
|
47
|
+
assert os.getenv("PYGENT_PERSONA") == "bot"
|
48
|
+
assert os.getenv("PYGENT_PERSONA_NAME") == "Bot"
|
49
|
+
assert os.getenv("PYGENT_TASK_PERSONAS") == os.pathsep.join(["a", "b"])
|
50
|
+
data = json.loads(os.getenv("PYGENT_TASK_PERSONAS_JSON"))
|
51
|
+
assert data[0]["description"] == "desc a"
|
52
|
+
assert os.getenv("PYGENT_INIT_FILES") == "seed.txt"
|
53
|
+
rt = Runtime(use_docker=False)
|
54
|
+
assert (rt.base_dir / "seed.txt").exists()
|
55
|
+
rt.cleanup()
|
56
|
+
|
57
|
+
|
58
|
+
def test_task_manager_personas(monkeypatch):
|
59
|
+
created = []
|
60
|
+
|
61
|
+
def factory(p):
|
62
|
+
created.append(p)
|
63
|
+
ag = types.SimpleNamespace(
|
64
|
+
runtime=Runtime(use_docker=False), model=None, persona=p
|
65
|
+
)
|
66
|
+
ag.run_until_stop = lambda *a, **k: None
|
67
|
+
return ag
|
68
|
+
|
69
|
+
tm = TaskManager(
|
70
|
+
agent_factory=factory, personas=[Persona("one", ""), Persona("two", "")]
|
71
|
+
)
|
72
|
+
tm.start_task("noop", Runtime(use_docker=False))
|
73
|
+
tm.start_task("noop", Runtime(use_docker=False))
|
74
|
+
tm.tasks[next(iter(tm.tasks))].thread.join()
|
75
|
+
assert [p.name for p in created] == ["one", "two"]
|
@@ -2,70 +2,74 @@ import os
|
|
2
2
|
import sys
|
3
3
|
import types
|
4
4
|
import time
|
5
|
+
import json
|
5
6
|
|
6
|
-
sys.modules.setdefault(
|
7
|
-
sys.modules.setdefault(
|
7
|
+
sys.modules.setdefault("openai", types.ModuleType("openai"))
|
8
|
+
sys.modules.setdefault("docker", types.ModuleType("docker"))
|
8
9
|
|
9
10
|
# mocks for rich
|
10
|
-
rich_mod = types.ModuleType(
|
11
|
-
console_mod = types.ModuleType(
|
12
|
-
panel_mod = types.ModuleType(
|
13
|
-
markdown_mod = types.ModuleType(
|
14
|
-
syntax_mod = types.ModuleType(
|
15
|
-
console_mod.Console = lambda *a, **k: type(
|
11
|
+
rich_mod = types.ModuleType("rich")
|
12
|
+
console_mod = types.ModuleType("console")
|
13
|
+
panel_mod = types.ModuleType("panel")
|
14
|
+
markdown_mod = types.ModuleType("markdown")
|
15
|
+
syntax_mod = types.ModuleType("syntax")
|
16
|
+
console_mod.Console = lambda *a, **k: type("C", (), {"print": lambda *a, **k: None})()
|
16
17
|
panel_mod.Panel = lambda *a, **k: None
|
17
18
|
markdown_mod.Markdown = lambda *a, **k: None
|
18
19
|
syntax_mod.Syntax = lambda *a, **k: None
|
19
|
-
sys.modules.setdefault(
|
20
|
-
sys.modules.setdefault(
|
21
|
-
sys.modules.setdefault(
|
22
|
-
sys.modules.setdefault(
|
23
|
-
sys.modules.setdefault(
|
20
|
+
sys.modules.setdefault("rich", rich_mod)
|
21
|
+
sys.modules.setdefault("rich.console", console_mod)
|
22
|
+
sys.modules.setdefault("rich.panel", panel_mod)
|
23
|
+
sys.modules.setdefault("rich.markdown", markdown_mod)
|
24
|
+
sys.modules.setdefault("rich.syntax", syntax_mod)
|
24
25
|
|
25
|
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__),
|
26
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
26
27
|
|
27
28
|
from pygent import Agent
|
28
29
|
from pygent import openai_compat
|
29
30
|
from pygent.task_manager import TaskManager
|
31
|
+
from pygent.persona import Persona
|
30
32
|
from pygent.runtime import Runtime
|
31
33
|
from pygent import tools
|
32
34
|
|
35
|
+
|
33
36
|
class DummyModel:
|
34
37
|
def __init__(self):
|
35
38
|
self.count = 0
|
39
|
+
|
36
40
|
def chat(self, messages, model, tool_schemas):
|
37
41
|
self.count += 1
|
38
42
|
if self.count == 1:
|
39
43
|
return openai_compat.Message(
|
40
|
-
role=
|
44
|
+
role="assistant",
|
41
45
|
content=None,
|
42
46
|
tool_calls=[
|
43
47
|
openai_compat.ToolCall(
|
44
|
-
id=
|
45
|
-
type=
|
48
|
+
id="1",
|
49
|
+
type="function",
|
46
50
|
function=openai_compat.ToolCallFunction(
|
47
|
-
name=
|
48
|
-
arguments='{"path": "foo.txt", "content": "bar"}'
|
49
|
-
)
|
51
|
+
name="write_file",
|
52
|
+
arguments='{"path": "foo.txt", "content": "bar"}',
|
53
|
+
),
|
50
54
|
)
|
51
|
-
]
|
55
|
+
],
|
52
56
|
)
|
53
57
|
else:
|
54
58
|
return openai_compat.Message(
|
55
|
-
role=
|
59
|
+
role="assistant",
|
56
60
|
content=None,
|
57
61
|
tool_calls=[
|
58
62
|
openai_compat.ToolCall(
|
59
|
-
id=
|
60
|
-
type=
|
63
|
+
id="2",
|
64
|
+
type="function",
|
61
65
|
function=openai_compat.ToolCallFunction(
|
62
|
-
name=
|
63
|
-
|
64
|
-
)
|
66
|
+
name="stop", arguments="{}"
|
67
|
+
),
|
65
68
|
)
|
66
|
-
]
|
69
|
+
],
|
67
70
|
)
|
68
71
|
|
72
|
+
|
69
73
|
class SlowModel(DummyModel):
|
70
74
|
"""DummyModel variant that sleeps to simulate long-running tasks."""
|
71
75
|
|
@@ -73,29 +77,32 @@ class SlowModel(DummyModel):
|
|
73
77
|
time.sleep(0.1)
|
74
78
|
return super().chat(messages, model, tool_schemas)
|
75
79
|
|
80
|
+
|
76
81
|
def make_agent():
|
77
82
|
return Agent(runtime=Runtime(use_docker=False), model=DummyModel())
|
78
83
|
|
84
|
+
|
79
85
|
def make_slow_agent():
|
80
86
|
return Agent(runtime=Runtime(use_docker=False), model=SlowModel())
|
81
87
|
|
88
|
+
|
82
89
|
def test_delegate_and_collect_file(tmp_path):
|
83
90
|
tm = TaskManager(agent_factory=make_agent)
|
84
91
|
tools._task_manager = tm
|
85
92
|
|
86
93
|
rt = Runtime(use_docker=False)
|
87
|
-
task_id = tools._delegate_task(rt, prompt=
|
94
|
+
task_id = tools._delegate_task(rt, prompt="run")
|
88
95
|
tid = task_id.split()[-1]
|
89
96
|
tm.tasks[tid].thread.join()
|
90
97
|
|
91
98
|
status = tools._task_status(Runtime(use_docker=False), task_id=tid)
|
92
|
-
assert status ==
|
99
|
+
assert status == "finished"
|
93
100
|
|
94
101
|
main_rt = Runtime(use_docker=False)
|
95
|
-
msg = tools._collect_file(main_rt, task_id=tid, path=
|
96
|
-
assert
|
97
|
-
copied = main_rt.base_dir /
|
98
|
-
assert copied.exists() and copied.read_text() ==
|
102
|
+
msg = tools._collect_file(main_rt, task_id=tid, path="foo.txt")
|
103
|
+
assert "Retrieved" in msg
|
104
|
+
copied = main_rt.base_dir / "foo.txt"
|
105
|
+
assert copied.exists() and copied.read_text() == "bar"
|
99
106
|
main_rt.cleanup()
|
100
107
|
|
101
108
|
|
@@ -123,37 +130,36 @@ def test_download_file():
|
|
123
130
|
class DelegateModel:
|
124
131
|
def __init__(self):
|
125
132
|
self.count = 0
|
133
|
+
|
126
134
|
def chat(self, messages, model, tool_schemas):
|
127
135
|
self.count += 1
|
128
136
|
if self.count == 1:
|
129
137
|
return openai_compat.Message(
|
130
|
-
role=
|
138
|
+
role="assistant",
|
131
139
|
content=None,
|
132
140
|
tool_calls=[
|
133
141
|
openai_compat.ToolCall(
|
134
|
-
id=
|
135
|
-
type=
|
142
|
+
id="1",
|
143
|
+
type="function",
|
136
144
|
function=openai_compat.ToolCallFunction(
|
137
|
-
name=
|
138
|
-
|
139
|
-
)
|
145
|
+
name="delegate_task", arguments='{"prompt": "noop"}'
|
146
|
+
),
|
140
147
|
)
|
141
|
-
]
|
148
|
+
],
|
142
149
|
)
|
143
150
|
else:
|
144
151
|
return openai_compat.Message(
|
145
|
-
role=
|
152
|
+
role="assistant",
|
146
153
|
content=None,
|
147
154
|
tool_calls=[
|
148
155
|
openai_compat.ToolCall(
|
149
|
-
id=
|
150
|
-
type=
|
156
|
+
id="2",
|
157
|
+
type="function",
|
151
158
|
function=openai_compat.ToolCallFunction(
|
152
|
-
name=
|
153
|
-
|
154
|
-
)
|
159
|
+
name="stop", arguments="{}"
|
160
|
+
),
|
155
161
|
)
|
156
|
-
]
|
162
|
+
],
|
157
163
|
)
|
158
164
|
|
159
165
|
|
@@ -165,7 +171,7 @@ def test_no_nested_delegation():
|
|
165
171
|
tm = TaskManager(agent_factory=make_delegate_agent, max_tasks=2)
|
166
172
|
tools._task_manager = tm
|
167
173
|
|
168
|
-
tid_msg = tools._delegate_task(Runtime(use_docker=False), prompt=
|
174
|
+
tid_msg = tools._delegate_task(Runtime(use_docker=False), prompt="run")
|
169
175
|
tid = tid_msg.split()[-1]
|
170
176
|
tm.tasks[tid].thread.join()
|
171
177
|
|
@@ -177,27 +183,67 @@ def test_task_limit():
|
|
177
183
|
tm = TaskManager(agent_factory=make_slow_agent, max_tasks=1)
|
178
184
|
tools._task_manager = tm
|
179
185
|
|
180
|
-
first = tools._delegate_task(Runtime(use_docker=False), prompt=
|
181
|
-
assert first.startswith(
|
186
|
+
first = tools._delegate_task(Runtime(use_docker=False), prompt="run")
|
187
|
+
assert first.startswith("started")
|
182
188
|
tid = first.split()[-1]
|
183
189
|
|
184
|
-
second = tools._delegate_task(Runtime(use_docker=False), prompt=
|
185
|
-
assert
|
190
|
+
second = tools._delegate_task(Runtime(use_docker=False), prompt="run")
|
191
|
+
assert "max" in second
|
186
192
|
|
187
193
|
tm.tasks[tid].thread.join()
|
188
194
|
|
189
195
|
|
190
196
|
def test_step_timeout():
|
191
197
|
ag = make_slow_agent()
|
192
|
-
ag.run_until_stop(
|
193
|
-
assert
|
198
|
+
ag.run_until_stop("run", step_timeout=0.05, max_steps=1)
|
199
|
+
assert "timeout" in ag.history[-1]["content"]
|
194
200
|
|
195
201
|
|
196
202
|
def test_task_timeout():
|
197
203
|
tm = TaskManager(agent_factory=make_slow_agent, max_tasks=1)
|
198
204
|
tools._task_manager = tm
|
199
205
|
rt = Runtime(use_docker=False)
|
200
|
-
tid = tm.start_task(
|
206
|
+
tid = tm.start_task("run", rt, task_timeout=0.05, step_timeout=0.01)
|
207
|
+
tm.tasks[tid].thread.join()
|
208
|
+
assert "timeout" in tm.status(tid)
|
209
|
+
|
210
|
+
|
211
|
+
def test_delegate_persona_task():
|
212
|
+
created = []
|
213
|
+
|
214
|
+
def factory(p):
|
215
|
+
created.append(p)
|
216
|
+
ag = types.SimpleNamespace(
|
217
|
+
runtime=Runtime(use_docker=False), model=None, persona=p
|
218
|
+
)
|
219
|
+
ag.run_until_stop = lambda *a, **k: None
|
220
|
+
return ag
|
221
|
+
|
222
|
+
tm = TaskManager(agent_factory=factory)
|
223
|
+
tools._task_manager = tm
|
224
|
+
|
225
|
+
rt = Runtime(use_docker=False)
|
226
|
+
tid_msg = tools._delegate_persona_task(rt, prompt="run", persona="tester")
|
227
|
+
tid = tid_msg.split()[-1]
|
201
228
|
tm.tasks[tid].thread.join()
|
202
|
-
assert 'timeout' in tm.status(tid)
|
203
229
|
|
230
|
+
assert [p.name for p in created] == ["tester"]
|
231
|
+
|
232
|
+
|
233
|
+
def test_list_personas():
|
234
|
+
tm = TaskManager(personas=[Persona("a", ""), Persona("b", "")])
|
235
|
+
tools._task_manager = tm
|
236
|
+
rt = Runtime(use_docker=False)
|
237
|
+
result = tools._list_personas(rt)
|
238
|
+
assert json.loads(result) == [
|
239
|
+
{"name": "a", "description": ""},
|
240
|
+
{"name": "b", "description": ""},
|
241
|
+
]
|
242
|
+
|
243
|
+
|
244
|
+
def test_personas_from_env(monkeypatch):
|
245
|
+
monkeypatch.setenv(
|
246
|
+
"PYGENT_TASK_PERSONAS_JSON", '[{"name":"env","description":"desc"}]'
|
247
|
+
)
|
248
|
+
tm = TaskManager()
|
249
|
+
assert tm.personas[0].name == "env" and tm.personas[0].description == "desc"
|
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
|