pygent 0.1.14__py3-none-any.whl → 0.1.16__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pygent/__init__.py CHANGED
@@ -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",
pygent/agent.py CHANGED
@@ -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
- DEFAULT_MODEL = os.getenv("PYGENT_MODEL", "gpt-4.1-mini")
20
- SYSTEM_MSG = (
21
- "You are Pygent, a sandboxed coding assistant.\n"
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
- system_msg: str = SYSTEM_MSG
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
 
pygent/cli.py CHANGED
@@ -1,12 +1,16 @@
1
1
  """Command-line entry point for Pygent."""
2
2
  import argparse
3
3
 
4
- from .agent import run_interactive
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)
pygent/config.py ADDED
@@ -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
pygent/persona.py ADDED
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class Persona:
5
+ """Representa uma persona com nome e descricao."""
6
+ name: str
7
+ description: str
8
+
pygent/runtime.py CHANGED
@@ -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__(self, image: str | None = None, use_docker: bool | None = None) -> None:
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.
pygent/task_manager.py CHANGED
@@ -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[[], "Agent"] | None = None,
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
- self.agent_factory = agent_factory or Agent
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 # default 5 minutes
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 # default 20 minutes
69
-
70
- agent = self.agent_factory()
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:
pygent/tools.py CHANGED
@@ -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.14
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` &ndash; set to `0` to disable Docker and run locally.
58
58
  * `PYGENT_MAX_TASKS` &ndash; 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.
@@ -0,0 +1,20 @@
1
+ pygent/__init__.py,sha256=PnTyTP8ObRQfKN0_8-BendG2gGiZhPz5iP9URrlPVwU,776
2
+ pygent/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ pygent/agent.py,sha256=55AouUMWIIL2ZPso9VHhCcGL3qRqvNXi42ynDWsdLvk,4589
4
+ pygent/cli.py,sha256=MixuFYGWdZXka6p6ccl4uEoGKFAS5l3xDdUPzcq7y3g,670
5
+ pygent/config.py,sha256=vJ8-w935vBqT1gfZ6eBxu7Z0VbRkpmEUqJEhqUAD5ic,2341
6
+ pygent/errors.py,sha256=s5FBg_v94coSgMh7cfkP4hVXafViGYgCY8QiT698-c4,155
7
+ pygent/models.py,sha256=j3670gjUtvQRGZ5wqGDcQ7ZJVTdT5WiwL7nWTokeYzg,1141
8
+ pygent/openai_compat.py,sha256=cyWFtXt6sDfOlsZd3FuRxbcZMm3WU-DLPBQpbmcuiW8,2617
9
+ pygent/persona.py,sha256=aHjRREHxkqK1uaUyWZuLUEh-RBecmMM5AlYgQsogfQw,152
10
+ pygent/py.typed,sha256=0Wh72UpGSn4lSGW-u3xMV9kxcBHMdwE15IGUqiJTwqo,52
11
+ pygent/runtime.py,sha256=dXk4mcYWdc3UzWN4WgyH-fsAUOlqR9L7cYAAf55Gu50,5121
12
+ pygent/task_manager.py,sha256=w_G0B3bfRZHQbdlDsxOJfWFYZQTSiPGlI84lgxdC2Hk,6216
13
+ pygent/tools.py,sha256=B-Czn4dYqOf5Mq_vzKYphX18Z8KUJJwhWqutqZVv3JE,7304
14
+ pygent/ui.py,sha256=xqPAvweghPOBBvoD72HzhN6zlXew_3inb8AN7Ck2zpQ,1328
15
+ pygent-0.1.16.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
16
+ pygent-0.1.16.dist-info/METADATA,sha256=BubYT3xEcrbgLO8dIHe9kNTEzLnpYSaAC3cL79BuEm0,4721
17
+ pygent-0.1.16.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
18
+ pygent-0.1.16.dist-info/entry_points.txt,sha256=b9j216E5UpuMrQWRZrwyEmacNEAYvw1tCKkZqdIVIOc,70
19
+ pygent-0.1.16.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
20
+ pygent-0.1.16.dist-info/RECORD,,
@@ -1,18 +0,0 @@
1
- pygent/__init__.py,sha256=8TB-wzDeQV0YANON-q7sUkzlIyAmTN6gyz4R-axmoYU,724
2
- pygent/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
- pygent/agent.py,sha256=5aY5HRlnLNwZzU_JD4bdEd9P9JLK3mtV4NJl7go9igY,4077
4
- pygent/cli.py,sha256=Hz2FZeNMVhxoT5DjCqphXla3TisGJtPEz921LEcpxrA,527
5
- pygent/errors.py,sha256=s5FBg_v94coSgMh7cfkP4hVXafViGYgCY8QiT698-c4,155
6
- pygent/models.py,sha256=j3670gjUtvQRGZ5wqGDcQ7ZJVTdT5WiwL7nWTokeYzg,1141
7
- pygent/openai_compat.py,sha256=cyWFtXt6sDfOlsZd3FuRxbcZMm3WU-DLPBQpbmcuiW8,2617
8
- pygent/py.typed,sha256=0Wh72UpGSn4lSGW-u3xMV9kxcBHMdwE15IGUqiJTwqo,52
9
- pygent/runtime.py,sha256=KckSdTSBIooz181ef8krXbSgqTxIaYDMdCy0ryOi7KQ,4386
10
- pygent/task_manager.py,sha256=kQ2wwBkhPtpluPzKe9T9Hiv1NoxUA3-sEvuIiPqxq90,4167
11
- pygent/tools.py,sha256=xHpUpG2QcBEvwTilpw8BDlYWY3KG9mItSt0FoKNkohA,5585
12
- pygent/ui.py,sha256=xqPAvweghPOBBvoD72HzhN6zlXew_3inb8AN7Ck2zpQ,1328
13
- pygent-0.1.14.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
14
- pygent-0.1.14.dist-info/METADATA,sha256=Et3MB9nI0I9Wb9eWEAmKUrd52rS6H9TluAarCzQsiOk,4278
15
- pygent-0.1.14.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- pygent-0.1.14.dist-info/entry_points.txt,sha256=b9j216E5UpuMrQWRZrwyEmacNEAYvw1tCKkZqdIVIOc,70
17
- pygent-0.1.14.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
18
- pygent-0.1.14.dist-info/RECORD,,