pygent 0.1.15__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.
Files changed (34) hide show
  1. {pygent-0.1.15 → pygent-0.1.16}/PKG-INFO +7 -1
  2. {pygent-0.1.15 → pygent-0.1.16}/README.md +6 -0
  3. {pygent-0.1.15 → pygent-0.1.16}/pygent/agent.py +8 -4
  4. {pygent-0.1.15 → pygent-0.1.16}/pygent/config.py +24 -7
  5. pygent-0.1.16/pygent/persona.py +8 -0
  6. {pygent-0.1.15 → pygent-0.1.16}/pygent/task_manager.py +43 -10
  7. {pygent-0.1.15 → pygent-0.1.16}/pygent/tools.py +54 -0
  8. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/PKG-INFO +7 -1
  9. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/SOURCES.txt +1 -0
  10. {pygent-0.1.15 → pygent-0.1.16}/pyproject.toml +1 -1
  11. pygent-0.1.16/tests/test_config.py +75 -0
  12. {pygent-0.1.15 → pygent-0.1.16}/tests/test_tasks.py +103 -57
  13. pygent-0.1.15/tests/test_config.py +0 -56
  14. {pygent-0.1.15 → pygent-0.1.16}/LICENSE +0 -0
  15. {pygent-0.1.15 → pygent-0.1.16}/pygent/__init__.py +0 -0
  16. {pygent-0.1.15 → pygent-0.1.16}/pygent/__main__.py +0 -0
  17. {pygent-0.1.15 → pygent-0.1.16}/pygent/cli.py +0 -0
  18. {pygent-0.1.15 → pygent-0.1.16}/pygent/errors.py +0 -0
  19. {pygent-0.1.15 → pygent-0.1.16}/pygent/models.py +0 -0
  20. {pygent-0.1.15 → pygent-0.1.16}/pygent/openai_compat.py +0 -0
  21. {pygent-0.1.15 → pygent-0.1.16}/pygent/py.typed +0 -0
  22. {pygent-0.1.15 → pygent-0.1.16}/pygent/runtime.py +0 -0
  23. {pygent-0.1.15 → pygent-0.1.16}/pygent/ui.py +0 -0
  24. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/dependency_links.txt +0 -0
  25. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/entry_points.txt +0 -0
  26. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/requires.txt +0 -0
  27. {pygent-0.1.15 → pygent-0.1.16}/pygent.egg-info/top_level.txt +0 -0
  28. {pygent-0.1.15 → pygent-0.1.16}/setup.cfg +0 -0
  29. {pygent-0.1.15 → pygent-0.1.16}/tests/test_autorun.py +0 -0
  30. {pygent-0.1.15 → pygent-0.1.16}/tests/test_custom_model.py +0 -0
  31. {pygent-0.1.15 → pygent-0.1.16}/tests/test_error_handling.py +0 -0
  32. {pygent-0.1.15 → pygent-0.1.16}/tests/test_runtime.py +0 -0
  33. {pygent-0.1.15 → pygent-0.1.16}/tests/test_tools.py +0 -0
  34. {pygent-0.1.15 → 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.15
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:
@@ -35,6 +35,12 @@ Behaviour can be adjusted via environment variables (see `docs/configuration.md`
35
35
  * `PYGENT_USE_DOCKER` &ndash; set to `0` to disable Docker and run locally.
36
36
  * `PYGENT_MAX_TASKS` &ndash; 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:
@@ -15,12 +15,16 @@ 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_PERSONA = os.getenv("PYGENT_PERSONA", "You are Pygent, a sandboxed coding assistant.")
20
+ DEFAULT_PERSONA = Persona(
21
+ os.getenv("PYGENT_PERSONA_NAME", "Pygent"),
22
+ os.getenv("PYGENT_PERSONA", "a sandboxed coding assistant."),
23
+ )
20
24
 
21
- def build_system_msg(persona: str) -> str:
25
+ def build_system_msg(persona: Persona) -> str:
22
26
  return (
23
- f"{persona}\n"
27
+ f"You are {persona.name}. {persona.description}\n"
24
28
  "Respond with JSON when you need to use a tool."
25
29
  "If you need to stop or finished you task, call the `stop` tool.\n"
26
30
  "You can use the following tools:\n"
@@ -41,7 +45,7 @@ class Agent:
41
45
  runtime: Runtime = field(default_factory=Runtime)
42
46
  model: Model = field(default_factory=OpenAIModel)
43
47
  model_name: str = DEFAULT_MODEL
44
- persona: str = DEFAULT_PERSONA
48
+ persona: Persona = field(default_factory=lambda: DEFAULT_PERSONA)
45
49
  system_msg: str = field(default_factory=lambda: build_system_msg(DEFAULT_PERSONA))
46
50
  history: List[Dict[str, Any]] = field(default_factory=list)
47
51
 
@@ -1,13 +1,15 @@
1
1
  import os
2
+ import json
2
3
  import tomllib
3
4
  from pathlib import Path
4
- from typing import Any, Dict
5
+ from typing import Any, Dict, List, Mapping
5
6
 
6
7
  DEFAULT_CONFIG_FILES = [
7
8
  Path("pygent.toml"),
8
9
  Path.home() / ".pygent.toml",
9
10
  ]
10
11
 
12
+
11
13
  def load_config(path: str | os.PathLike[str] | None = None) -> Dict[str, Any]:
12
14
  """Load configuration from a TOML file and set environment variables.
13
15
 
@@ -27,14 +29,29 @@ def load_config(path: str | os.PathLike[str] | None = None) -> Dict[str, Any]:
27
29
  # update environment without overwriting existing values
28
30
  if "persona" in config and "PYGENT_PERSONA" not in os.environ:
29
31
  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"])
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)
35
50
  if "initial_files" in config and "PYGENT_INIT_FILES" not in os.environ:
36
51
  if isinstance(config["initial_files"], list):
37
- os.environ["PYGENT_INIT_FILES"] = os.pathsep.join(str(p) for p in config["initial_files"])
52
+ os.environ["PYGENT_INIT_FILES"] = os.pathsep.join(
53
+ str(p) for p in config["initial_files"]
54
+ )
38
55
  else:
39
56
  os.environ["PYGENT_INIT_FILES"] = str(config["initial_files"])
40
57
  return config
@@ -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
+
@@ -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
@@ -32,7 +35,7 @@ class TaskManager:
32
35
  self,
33
36
  agent_factory: Callable[..., "Agent"] | None = None,
34
37
  max_tasks: int | None = None,
35
- personas: list[str] | None = None,
38
+ personas: list[Persona] | None = None,
36
39
  ) -> None:
37
40
  from .agent import Agent # local import to avoid circular dependency
38
41
 
@@ -42,11 +45,33 @@ class TaskManager:
42
45
  self.agent_factory = lambda p=None: Agent(persona=p)
43
46
  else:
44
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
45
60
  env_personas = os.getenv("PYGENT_TASK_PERSONAS")
46
61
  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]
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
50
75
  self._persona_idx = 0
51
76
  self.tasks: Dict[str, Task] = {}
52
77
  self._lock = threading.Lock()
@@ -59,8 +84,12 @@ class TaskManager:
59
84
  parent_depth: int = 0,
60
85
  step_timeout: float | None = None,
61
86
  task_timeout: float | None = None,
87
+ persona: Persona | str | None = None,
62
88
  ) -> str:
63
- """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
+ """
64
93
 
65
94
  if parent_depth >= 1:
66
95
  raise RuntimeError("nested delegation is not allowed")
@@ -72,13 +101,17 @@ class TaskManager:
72
101
 
73
102
  if step_timeout is None:
74
103
  env = os.getenv("PYGENT_STEP_TIMEOUT")
75
- 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
76
105
  if task_timeout is None:
77
106
  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
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, "")
82
115
  try:
83
116
  agent = self.agent_factory(persona)
84
117
  except TypeError:
@@ -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.15
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:
@@ -9,6 +9,7 @@ pygent/config.py
9
9
  pygent/errors.py
10
10
  pygent/models.py
11
11
  pygent/openai_compat.py
12
+ pygent/persona.py
12
13
  pygent/py.typed
13
14
  pygent/runtime.py
14
15
  pygent/task_manager.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pygent"
3
- version = "0.1.15"
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('openai', types.ModuleType('openai'))
7
- sys.modules.setdefault('docker', types.ModuleType('docker'))
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('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})()
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('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)
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='assistant',
44
+ role="assistant",
41
45
  content=None,
42
46
  tool_calls=[
43
47
  openai_compat.ToolCall(
44
- id='1',
45
- type='function',
48
+ id="1",
49
+ type="function",
46
50
  function=openai_compat.ToolCallFunction(
47
- name='write_file',
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='assistant',
59
+ role="assistant",
56
60
  content=None,
57
61
  tool_calls=[
58
62
  openai_compat.ToolCall(
59
- id='2',
60
- type='function',
63
+ id="2",
64
+ type="function",
61
65
  function=openai_compat.ToolCallFunction(
62
- name='stop',
63
- arguments='{}'
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='run')
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 == 'finished'
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='foo.txt')
96
- assert 'Retrieved' in msg
97
- copied = main_rt.base_dir / 'foo.txt'
98
- assert copied.exists() and copied.read_text() == 'bar'
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='assistant',
138
+ role="assistant",
131
139
  content=None,
132
140
  tool_calls=[
133
141
  openai_compat.ToolCall(
134
- id='1',
135
- type='function',
142
+ id="1",
143
+ type="function",
136
144
  function=openai_compat.ToolCallFunction(
137
- name='delegate_task',
138
- arguments='{"prompt": "noop"}'
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='assistant',
152
+ role="assistant",
146
153
  content=None,
147
154
  tool_calls=[
148
155
  openai_compat.ToolCall(
149
- id='2',
150
- type='function',
156
+ id="2",
157
+ type="function",
151
158
  function=openai_compat.ToolCallFunction(
152
- name='stop',
153
- arguments='{}'
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='run')
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='run')
181
- assert first.startswith('started')
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='run')
185
- assert 'max' in second
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('run', step_timeout=0.05, max_steps=1)
193
- assert 'timeout' in ag.history[-1]["content"]
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('run', rt, task_timeout=0.05, step_timeout=0.01)
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"
@@ -1,56 +0,0 @@
1
- import os
2
- import sys
3
- import types
4
-
5
- sys.modules.setdefault('openai', types.ModuleType('openai'))
6
- sys.modules.setdefault('docker', types.ModuleType('docker'))
7
-
8
- # minimal mocks for rich
9
- rich_mod = types.ModuleType('rich')
10
- console_mod = types.ModuleType('console')
11
- panel_mod = types.ModuleType('panel')
12
- markdown_mod = types.ModuleType('markdown')
13
- console_mod.Console = lambda *a, **k: None
14
- panel_mod.Panel = lambda *a, **k: None
15
- markdown_mod.Markdown = lambda *a, **k: None
16
- sys.modules.setdefault('rich', rich_mod)
17
- sys.modules.setdefault('rich.console', console_mod)
18
- sys.modules.setdefault('rich.panel', panel_mod)
19
- sys.modules.setdefault('rich.markdown', markdown_mod)
20
-
21
- sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
22
-
23
- from pygent.config import load_config
24
- from pygent.runtime import Runtime
25
- from pygent.task_manager import TaskManager
26
-
27
-
28
- def test_load_config(tmp_path, monkeypatch):
29
- cfg = tmp_path / "pygent.toml"
30
- cfg.write_text('persona="bot"\ntask_personas=["a","b"]\ninitial_files=["seed.txt"]')
31
- (tmp_path / "seed.txt").write_text("seed")
32
- monkeypatch.chdir(tmp_path)
33
- monkeypatch.delenv("PYGENT_PERSONA", raising=False)
34
- monkeypatch.delenv("PYGENT_TASK_PERSONAS", raising=False)
35
- monkeypatch.delenv("PYGENT_INIT_FILES", raising=False)
36
- load_config()
37
- assert os.getenv("PYGENT_PERSONA") == "bot"
38
- assert os.getenv("PYGENT_TASK_PERSONAS") == os.pathsep.join(["a", "b"])
39
- assert os.getenv("PYGENT_INIT_FILES") == "seed.txt"
40
- rt = Runtime(use_docker=False)
41
- assert (rt.base_dir / "seed.txt").exists()
42
- rt.cleanup()
43
-
44
-
45
- def test_task_manager_personas(monkeypatch):
46
- created = []
47
- def factory(p):
48
- created.append(p)
49
- ag = types.SimpleNamespace(runtime=Runtime(use_docker=False), model=None, persona=p)
50
- ag.run_until_stop = lambda *a, **k: None
51
- return ag
52
- tm = TaskManager(agent_factory=factory, personas=["one","two"])
53
- tm.start_task("noop", Runtime(use_docker=False))
54
- tm.start_task("noop", Runtime(use_docker=False))
55
- tm.tasks[next(iter(tm.tasks))].thread.join()
56
- assert created == ["one","two"]
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