pygent 0.1.13__py3-none-any.whl → 0.1.15__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
@@ -16,15 +16,20 @@ from .runtime import Runtime
16
16
  from . import tools
17
17
  from .models import Model, OpenAIModel
18
18
 
19
+ DEFAULT_PERSONA = os.getenv("PYGENT_PERSONA", "You are Pygent, a sandboxed coding assistant.")
20
+
21
+ def build_system_msg(persona: str) -> str:
22
+ return (
23
+ f"{persona}\n"
24
+ "Respond with JSON when you need to use a tool."
25
+ "If you need to stop or finished you task, call the `stop` tool.\n"
26
+ "You can use the following tools:\n"
27
+ f"{json.dumps(tools.TOOL_SCHEMAS, indent=2)}\n"
28
+ "You can also use the `continue` tool to request user input or continue the conversation.\n"
29
+ )
30
+
19
31
  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"
27
- )
32
+ SYSTEM_MSG = build_system_msg(DEFAULT_PERSONA)
28
33
 
29
34
  console = Console()
30
35
 
@@ -36,15 +41,21 @@ class Agent:
36
41
  runtime: Runtime = field(default_factory=Runtime)
37
42
  model: Model = field(default_factory=OpenAIModel)
38
43
  model_name: str = DEFAULT_MODEL
39
- system_msg: str = SYSTEM_MSG
44
+ persona: str = DEFAULT_PERSONA
45
+ system_msg: str = field(default_factory=lambda: build_system_msg(DEFAULT_PERSONA))
40
46
  history: List[Dict[str, Any]] = field(default_factory=list)
41
47
 
42
48
  def __post_init__(self) -> None:
49
+ if not self.system_msg:
50
+ self.system_msg = build_system_msg(self.persona)
43
51
  if not self.history:
44
52
  self.history.append({"role": "system", "content": self.system_msg})
45
53
 
46
54
  def step(self, user_msg: str):
55
+ """Execute one round of interaction with the model."""
56
+
47
57
  self.history.append({"role": "user", "content": user_msg})
58
+
48
59
  assistant_msg = self.model.chat(
49
60
  self.history, self.model_name, tools.TOOL_SCHEMAS
50
61
  )
@@ -60,12 +71,36 @@ class Agent:
60
71
  console.print(Panel(markdown_response, title="Resposta do Agente", title_align="left", border_style="cyan"))
61
72
  return assistant_msg
62
73
 
63
- def run_until_stop(self, user_msg: str, max_steps: int = 10) -> None:
64
- """Run steps automatically until the model calls the ``stop`` tool or
65
- the step limit is reached."""
74
+ def run_until_stop(
75
+ self,
76
+ user_msg: str,
77
+ max_steps: int = 20,
78
+ step_timeout: float | None = None,
79
+ max_time: float | None = None,
80
+ ) -> None:
81
+ """Run steps until ``stop`` is called or limits are reached."""
82
+
83
+ if step_timeout is None:
84
+ env = os.getenv("PYGENT_STEP_TIMEOUT")
85
+ step_timeout = float(env) if env else None
86
+ if max_time is None:
87
+ env = os.getenv("PYGENT_TASK_TIMEOUT")
88
+ max_time = float(env) if env else None
89
+
66
90
  msg = user_msg
91
+ start = time.monotonic()
92
+ self._timed_out = False
67
93
  for _ in range(max_steps):
94
+ if max_time is not None and time.monotonic() - start > max_time:
95
+ self.history.append({"role": "system", "content": f"[timeout after {max_time}s]"})
96
+ self._timed_out = True
97
+ break
98
+ step_start = time.monotonic()
68
99
  assistant_msg = self.step(msg)
100
+ if step_timeout is not None and time.monotonic() - step_start > step_timeout:
101
+ self.history.append({"role": "system", "content": f"[timeout after {step_timeout}s]"})
102
+ self._timed_out = True
103
+ break
69
104
  calls = assistant_msg.tool_calls or []
70
105
  if any(c.function.name in ("stop", "continue") for c in calls):
71
106
  break
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,40 @@
1
+ import os
2
+ import tomllib
3
+ from pathlib import Path
4
+ from typing import Any, Dict
5
+
6
+ DEFAULT_CONFIG_FILES = [
7
+ Path("pygent.toml"),
8
+ Path.home() / ".pygent.toml",
9
+ ]
10
+
11
+ def load_config(path: str | os.PathLike[str] | None = None) -> Dict[str, Any]:
12
+ """Load configuration from a TOML file and set environment variables.
13
+
14
+ Environment variables already set take precedence over file values.
15
+ Returns the configuration dictionary.
16
+ """
17
+ config: Dict[str, Any] = {}
18
+ paths = [Path(path)] if path else DEFAULT_CONFIG_FILES
19
+ for p in paths:
20
+ if p.is_file():
21
+ with p.open("rb") as fh:
22
+ try:
23
+ data = tomllib.load(fh)
24
+ except Exception:
25
+ continue
26
+ config.update(data)
27
+ # update environment without overwriting existing values
28
+ if "persona" in config and "PYGENT_PERSONA" not in os.environ:
29
+ os.environ["PYGENT_PERSONA"] = str(config["persona"])
30
+ if "task_personas" in config and "PYGENT_TASK_PERSONAS" not in os.environ:
31
+ if isinstance(config["task_personas"], list):
32
+ os.environ["PYGENT_TASK_PERSONAS"] = os.pathsep.join(str(p) for p in config["task_personas"])
33
+ else:
34
+ os.environ["PYGENT_TASK_PERSONAS"] = str(config["task_personas"])
35
+ if "initial_files" in config and "PYGENT_INIT_FILES" not in os.environ:
36
+ if isinstance(config["initial_files"], list):
37
+ os.environ["PYGENT_INIT_FILES"] = os.pathsep.join(str(p) for p in config["initial_files"])
38
+ else:
39
+ os.environ["PYGENT_INIT_FILES"] = str(config["initial_files"])
40
+ return config
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
@@ -30,14 +30,24 @@ class TaskManager:
30
30
 
31
31
  def __init__(
32
32
  self,
33
- agent_factory: Callable[[], "Agent"] | None = None,
33
+ agent_factory: Callable[..., "Agent"] | None = None,
34
34
  max_tasks: int | None = None,
35
+ personas: list[str] | None = None,
35
36
  ) -> None:
36
37
  from .agent import Agent # local import to avoid circular dependency
37
38
 
38
39
  env_max = os.getenv("PYGENT_MAX_TASKS")
39
40
  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
+ if agent_factory is None:
42
+ self.agent_factory = lambda p=None: Agent(persona=p)
43
+ else:
44
+ self.agent_factory = agent_factory
45
+ env_personas = os.getenv("PYGENT_TASK_PERSONAS")
46
+ if personas is None and env_personas:
47
+ personas = [p.strip() for p in env_personas.split(os.pathsep) if p.strip()]
48
+ default_persona = os.getenv("PYGENT_PERSONA", "You are Pygent, a sandboxed coding assistant.")
49
+ self.personas = personas or [default_persona]
50
+ self._persona_idx = 0
41
51
  self.tasks: Dict[str, Task] = {}
42
52
  self._lock = threading.Lock()
43
53
 
@@ -47,6 +57,8 @@ class TaskManager:
47
57
  parent_rt: Runtime,
48
58
  files: list[str] | None = None,
49
59
  parent_depth: int = 0,
60
+ step_timeout: float | None = None,
61
+ task_timeout: float | None = None,
50
62
  ) -> str:
51
63
  """Create a new agent and run ``prompt`` asynchronously."""
52
64
 
@@ -58,7 +70,24 @@ class TaskManager:
58
70
  if active >= self.max_tasks:
59
71
  raise RuntimeError(f"max {self.max_tasks} tasks reached")
60
72
 
61
- agent = self.agent_factory()
73
+ if step_timeout is None:
74
+ env = os.getenv("PYGENT_STEP_TIMEOUT")
75
+ step_timeout = float(env) if env else 60*5 # default 5 minutes
76
+ if task_timeout is None:
77
+ env = os.getenv("PYGENT_TASK_TIMEOUT")
78
+ task_timeout = float(env) if env else 60*20 # default 20 minutes
79
+
80
+ persona = self.personas[self._persona_idx % len(self.personas)]
81
+ self._persona_idx += 1
82
+ try:
83
+ agent = self.agent_factory(persona)
84
+ except TypeError:
85
+ agent = self.agent_factory()
86
+ setattr(agent, "persona", persona)
87
+ if not getattr(agent, "system_msg", None):
88
+ from .agent import build_system_msg # lazy import
89
+
90
+ agent.system_msg = build_system_msg(persona)
62
91
  setattr(agent.runtime, "task_depth", parent_depth + 1)
63
92
  if files:
64
93
  for fp in files:
@@ -74,8 +103,15 @@ class TaskManager:
74
103
 
75
104
  def run() -> None:
76
105
  try:
77
- agent.run_until_stop(prompt)
78
- task.status = "finished"
106
+ agent.run_until_stop(
107
+ prompt,
108
+ step_timeout=step_timeout,
109
+ max_time=task_timeout,
110
+ )
111
+ if getattr(agent, "_timed_out", False):
112
+ task.status = f"timeout after {task_timeout}s"
113
+ else:
114
+ task.status = "finished"
79
115
  except Exception as exc: # pragma: no cover - error propagation
80
116
  task.status = f"error: {exc}"
81
117
 
pygent/tools.py CHANGED
@@ -122,11 +122,19 @@ 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
+ "timeout": {"type": "number", "description": "Max seconds for the task"},
126
+ "step_timeout": {"type": "number", "description": "Time limit per step"},
125
127
  },
126
128
  "required": ["prompt"],
127
129
  },
128
130
  )
129
- def _delegate_task(rt: Runtime, prompt: str, files: list[str] | None = None) -> str:
131
+ def _delegate_task(
132
+ rt: Runtime,
133
+ prompt: str,
134
+ files: list[str] | None = None,
135
+ timeout: float | None = None,
136
+ step_timeout: float | None = None,
137
+ ) -> str:
130
138
  if getattr(rt, "task_depth", 0) >= 1:
131
139
  return "error: delegation not allowed in sub-tasks"
132
140
  try:
@@ -135,6 +143,8 @@ def _delegate_task(rt: Runtime, prompt: str, files: list[str] | None = None) ->
135
143
  parent_rt=rt,
136
144
  files=files,
137
145
  parent_depth=getattr(rt, "task_depth", 0),
146
+ step_timeout=step_timeout,
147
+ task_timeout=timeout,
138
148
  )
139
149
  except RuntimeError as exc:
140
150
  return str(exc)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygent
3
- Version: 0.1.13
3
+ Version: 0.1.15
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
@@ -68,6 +68,7 @@ pygent
68
68
  Use `--docker` to run commands inside a container (requires
69
69
  `pygent[docker]`). Use `--no-docker` or set `PYGENT_USE_DOCKER=0`
70
70
  to force local execution.
71
+ Pass `--config path/to/pygent.toml` to load settings from a file.
71
72
 
72
73
  Type messages normally; use `/exit` to end the session. Each command is executed
73
74
  in the container and the result shown in the terminal.
@@ -0,0 +1,19 @@
1
+ pygent/__init__.py,sha256=PnTyTP8ObRQfKN0_8-BendG2gGiZhPz5iP9URrlPVwU,776
2
+ pygent/__main__.py,sha256=MSmt_5Xg84uHqzTN38JwgseJK8rsJn_11A8WD99VtEo,61
3
+ pygent/agent.py,sha256=0yo2K47PgvQJgH_y2xCCkjINks1pYoTQpztPn2J0srk,4437
4
+ pygent/cli.py,sha256=MixuFYGWdZXka6p6ccl4uEoGKFAS5l3xDdUPzcq7y3g,670
5
+ pygent/config.py,sha256=4Yn7kV62RaXa-DI5IqurgWvFZNNykjS0PS4MsUrFEzo,1636
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/py.typed,sha256=0Wh72UpGSn4lSGW-u3xMV9kxcBHMdwE15IGUqiJTwqo,52
10
+ pygent/runtime.py,sha256=dXk4mcYWdc3UzWN4WgyH-fsAUOlqR9L7cYAAf55Gu50,5121
11
+ pygent/task_manager.py,sha256=pO4A9nAfeUylHc2h94K5JUPXTy8MXEtBbw_oYb8oOQk,5106
12
+ pygent/tools.py,sha256=xHpUpG2QcBEvwTilpw8BDlYWY3KG9mItSt0FoKNkohA,5585
13
+ pygent/ui.py,sha256=xqPAvweghPOBBvoD72HzhN6zlXew_3inb8AN7Ck2zpQ,1328
14
+ pygent-0.1.15.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
15
+ pygent-0.1.15.dist-info/METADATA,sha256=Im7H9V0OHX487HxfIrYvN4m8HNElXzdT6kViNizeAw8,4344
16
+ pygent-0.1.15.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
17
+ pygent-0.1.15.dist-info/entry_points.txt,sha256=b9j216E5UpuMrQWRZrwyEmacNEAYvw1tCKkZqdIVIOc,70
18
+ pygent-0.1.15.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
19
+ pygent-0.1.15.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=6CfSg943GoT6roj5ZAH8nC5UrfxqmwEhIXHMYad2SFw,3073
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=Dq_rezUYC4bzXc3kQ9zLIcjUv3kNc9xzB9xTSDJAzFA,3482
11
- pygent/tools.py,sha256=HbV01OlRRfzRUf4p6lujtKcHEc43vduC4vv2hq9N1bM,5252
12
- pygent/ui.py,sha256=xqPAvweghPOBBvoD72HzhN6zlXew_3inb8AN7Ck2zpQ,1328
13
- pygent-0.1.13.dist-info/licenses/LICENSE,sha256=rIktBU2VR4kHzsWul64cbom2zHIgGqYmABoZwSur6T8,1071
14
- pygent-0.1.13.dist-info/METADATA,sha256=Mop-IQFJad5onR92vwdBexRjVk7FWIHE5_k3y9hS-wI,4278
15
- pygent-0.1.13.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
16
- pygent-0.1.13.dist-info/entry_points.txt,sha256=b9j216E5UpuMrQWRZrwyEmacNEAYvw1tCKkZqdIVIOc,70
17
- pygent-0.1.13.dist-info/top_level.txt,sha256=P26IYsb-ThK5IkGP_bRuGJQ0Q_Y8JCcbYqVpvULdxDw,7
18
- pygent-0.1.13.dist-info/RECORD,,