pyflue 0.1.0__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.
pyflue/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """PyFlue public API."""
2
+
3
+ from pyflue.core import PyFlueAgent, PyFlueSession, init
4
+ from pyflue.harnesses.registry import register_harness
5
+ from pyflue.skills import Skill, load_skills
6
+
7
+ __all__ = [
8
+ "PyFlueAgent",
9
+ "PyFlueSession",
10
+ "Skill",
11
+ "init",
12
+ "load_skills",
13
+ "register_harness",
14
+ ]
pyflue/cli.py ADDED
@@ -0,0 +1,259 @@
1
+ """PyFlue command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from pyflue import init
14
+
15
+ app = typer.Typer(help="PyFlue agent harness CLI.")
16
+ skill_app = typer.Typer(help="Manage Markdown skills.")
17
+ app.add_typer(skill_app, name="skill")
18
+ console = Console()
19
+ PROMPT_OPTION = typer.Option(..., "--prompt", "-p", help="Prompt to run.")
20
+ SESSION_OPTION = typer.Option("default", "--session", "-s")
21
+ CONFIG_OPTION = typer.Option("pyflue.toml", "--config")
22
+ ALLOW_WRITE_OPTION = typer.Option(False, "--allow-write")
23
+ ALLOW_SHELL_OPTION = typer.Option(False, "--allow-shell")
24
+ PORT_OPTION = typer.Option(2024, "--port")
25
+ PROJECT_NAME_ARGUMENT = typer.Argument("pyflue-agent")
26
+ SKILL_NAME_ARGUMENT = typer.Argument(...)
27
+ BuildTarget = Literal[
28
+ "docker",
29
+ "github-actions",
30
+ "gitlab-ci",
31
+ "railway",
32
+ "render",
33
+ "fly",
34
+ ]
35
+
36
+
37
+ @app.command("init")
38
+ def init_project(name: str = PROJECT_NAME_ARGUMENT, force: bool = False) -> None:
39
+ """Scaffold a PyFlue project."""
40
+ root = Path(name).resolve()
41
+ if root.exists() and any(root.iterdir()) and not force:
42
+ raise typer.BadParameter(f"{root} is not empty. Use --force to overwrite.")
43
+ (root / ".agents" / "skills").mkdir(parents=True, exist_ok=True)
44
+ (root / "AGENTS.md").write_text(
45
+ "You are a careful autonomous Python agent. Keep changes scoped.\n",
46
+ encoding="utf-8",
47
+ )
48
+ (root / "pyflue.toml").write_text(
49
+ '[agent]\nmodel = "openai:gpt-4o"\nharness = "deepagents"\nsandbox = "virtual"\n',
50
+ encoding="utf-8",
51
+ )
52
+ _write_skill(root / ".agents" / "skills" / "triage.md", "triage")
53
+ console.print(f"Created PyFlue project at {root}")
54
+
55
+
56
+ @app.command()
57
+ def run(
58
+ prompt: str = PROMPT_OPTION,
59
+ session: str = SESSION_OPTION,
60
+ config: Path = CONFIG_OPTION,
61
+ allow_write: bool = ALLOW_WRITE_OPTION,
62
+ allow_shell: bool = ALLOW_SHELL_OPTION,
63
+ ) -> None:
64
+ """Run one PyFlue prompt."""
65
+
66
+ async def _run() -> None:
67
+ agent = await init(
68
+ config_path=config,
69
+ allow_write=allow_write,
70
+ allow_shell=allow_shell,
71
+ )
72
+ result = await (await agent.session(session)).prompt(prompt)
73
+ console.print(result.text)
74
+
75
+ asyncio.run(_run())
76
+
77
+
78
+ @app.command()
79
+ def dev(port: int = PORT_OPTION, config: Path = CONFIG_OPTION) -> None:
80
+ """Start a development server with hot-reload support."""
81
+ console.print(
82
+ f"pyflue dev is planned for FastAPI hot reload. Config={config}, port={port}"
83
+ )
84
+
85
+
86
+ @app.command()
87
+ def build(target: BuildTarget = "docker") -> None:
88
+ """Generate deployment artifacts."""
89
+ if target == "docker":
90
+ _write_docker_artifacts()
91
+ console.print("Generated Dockerfile and app.py")
92
+ elif target == "github-actions":
93
+ _write_github_actions_workflow()
94
+ console.print("Generated .github/workflows/pyflue-agent.yml")
95
+ elif target == "gitlab-ci":
96
+ _write_gitlab_ci()
97
+ console.print("Generated .gitlab-ci.yml")
98
+ elif target == "railway":
99
+ _write_docker_artifacts()
100
+ Path("railway.json").write_text(
101
+ '{\n "$schema": "https://railway.app/railway.schema.json",\n'
102
+ ' "build": {"builder": "DOCKERFILE"},\n'
103
+ ' "deploy": {"startCommand": "uvicorn app:app --host 0.0.0.0 --port $PORT"}\n'
104
+ "}\n",
105
+ encoding="utf-8",
106
+ )
107
+ console.print("Generated Dockerfile, app.py, and railway.json")
108
+ elif target == "render":
109
+ _write_docker_artifacts()
110
+ Path("render.yaml").write_text(
111
+ "services:\n"
112
+ " - type: web\n"
113
+ " name: pyflue-agent\n"
114
+ " runtime: docker\n"
115
+ " plan: starter\n"
116
+ " envVars:\n"
117
+ " - key: PORT\n"
118
+ " value: 8000\n",
119
+ encoding="utf-8",
120
+ )
121
+ console.print("Generated Dockerfile, app.py, and render.yaml")
122
+ elif target == "fly":
123
+ _write_docker_artifacts()
124
+ Path("fly.toml").write_text(
125
+ 'app = "pyflue-agent"\n'
126
+ 'primary_region = "iad"\n\n'
127
+ "[http_service]\n"
128
+ " internal_port = 8000\n"
129
+ " force_https = true\n"
130
+ " auto_stop_machines = true\n"
131
+ " auto_start_machines = true\n",
132
+ encoding="utf-8",
133
+ )
134
+ console.print("Generated Dockerfile, app.py, and fly.toml")
135
+
136
+
137
+ @app.command()
138
+ def deploy(dry_run: bool = False) -> None:
139
+ """Deploy the PyFlue agent using the configured harness."""
140
+ console.print("Dry run: deployment config is valid." if dry_run else "pyflue deploy is planned.")
141
+
142
+
143
+ @skill_app.command("new")
144
+ def new_skill(name: str = SKILL_NAME_ARGUMENT) -> None:
145
+ """Create a new Markdown skill."""
146
+ path = Path(".agents") / "skills" / f"{name}.md"
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ _write_skill(path, name)
149
+ console.print(f"Created skill {path}")
150
+
151
+
152
+ def _write_skill(path: Path, name: str) -> None:
153
+ content = {
154
+ "name": name,
155
+ "description": f"{name} workflow",
156
+ "input_schema": {"type": "object", "properties": {}},
157
+ "output_schema": {"type": "object", "properties": {"summary": {"type": "string"}}},
158
+ }
159
+ frontmatter = "\n".join(
160
+ [
161
+ "---",
162
+ f"name: {content['name']}",
163
+ f"description: {content['description']}",
164
+ "input_schema:",
165
+ " type: object",
166
+ " properties: {}",
167
+ "output_schema:",
168
+ " type: object",
169
+ " properties:",
170
+ " summary:",
171
+ " type: string",
172
+ "---",
173
+ "",
174
+ "# Role",
175
+ "You are a PyFlue skill.",
176
+ "",
177
+ "## Instructions",
178
+ "Complete the requested workflow and return a concise result.",
179
+ ]
180
+ )
181
+ path.write_text(frontmatter, encoding="utf-8")
182
+
183
+
184
+ def _write_docker_artifacts() -> None:
185
+ Path("Dockerfile").write_text(
186
+ "FROM python:3.11-slim\n"
187
+ "WORKDIR /app\n"
188
+ "COPY . .\n"
189
+ "RUN pip install . fastapi uvicorn\n"
190
+ 'CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]\n',
191
+ encoding="utf-8",
192
+ )
193
+ Path("app.py").write_text(
194
+ "from fastapi import FastAPI\n"
195
+ "from pydantic import BaseModel\n\n"
196
+ "from pyflue import init\n\n\n"
197
+ "class PromptRequest(BaseModel):\n"
198
+ " prompt: str\n"
199
+ " session: str = \"default\"\n\n\n"
200
+ "app = FastAPI(title=\"PyFlue Agent\")\n\n\n"
201
+ "@app.post(\"/prompt\")\n"
202
+ "async def prompt(request: PromptRequest):\n"
203
+ " agent = await init()\n"
204
+ " session = await agent.session(request.session)\n"
205
+ " result = await session.prompt(request.prompt)\n"
206
+ " return {\"text\": result.text, \"metadata\": result.metadata}\n",
207
+ encoding="utf-8",
208
+ )
209
+
210
+
211
+ def _write_github_actions_workflow() -> None:
212
+ path = Path(".github") / "workflows" / "pyflue-agent.yml"
213
+ path.parent.mkdir(parents=True, exist_ok=True)
214
+ path.write_text(
215
+ "name: PyFlue Agent\n\n"
216
+ "on:\n"
217
+ " workflow_dispatch:\n"
218
+ " inputs:\n"
219
+ " prompt:\n"
220
+ " description: Prompt to run\n"
221
+ " required: true\n"
222
+ " default: Review this repository\n\n"
223
+ "jobs:\n"
224
+ " agent:\n"
225
+ " runs-on: ubuntu-latest\n"
226
+ " permissions:\n"
227
+ " contents: read\n"
228
+ " steps:\n"
229
+ " - uses: actions/checkout@v4\n"
230
+ " - uses: astral-sh/setup-uv@v5\n"
231
+ " - uses: actions/setup-python@v5\n"
232
+ " with:\n"
233
+ " python-version: '3.12'\n"
234
+ " - run: uv sync\n"
235
+ " - name: Run PyFlue agent\n"
236
+ " env:\n"
237
+ " OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}\n"
238
+ " run: uv run pyflue run --allow-shell --prompt \"${{ inputs.prompt }}\"\n",
239
+ encoding="utf-8",
240
+ )
241
+
242
+
243
+ def _write_gitlab_ci() -> None:
244
+ Path(".gitlab-ci.yml").write_text(
245
+ "pyflue-agent:\n"
246
+ " image: ghcr.io/astral-sh/uv:python3.12-bookworm-slim\n"
247
+ " rules:\n"
248
+ " - if: $CI_PIPELINE_SOURCE == \"web\"\n"
249
+ " variables:\n"
250
+ " PROMPT: \"Review this repository\"\n"
251
+ " script:\n"
252
+ " - uv sync\n"
253
+ " - uv run pyflue run --allow-shell --prompt \"$PROMPT\"\n",
254
+ encoding="utf-8",
255
+ )
256
+
257
+
258
+ def _parse_payload(payload: str | None) -> dict[str, Any]:
259
+ return json.loads(payload or "{}")
pyflue/config.py ADDED
@@ -0,0 +1,38 @@
1
+ """pyflue.toml loading."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from pyflue.types import PyFlueConfig
10
+
11
+
12
+ def load_config(path: str | Path = "pyflue.toml") -> PyFlueConfig:
13
+ """Load PyFlue configuration from TOML."""
14
+ config_path = Path(path).expanduser()
15
+ root = config_path.parent.resolve() if config_path.exists() else Path.cwd()
16
+ data: dict[str, Any] = {}
17
+ if config_path.exists():
18
+ data = tomllib.loads(config_path.read_text(encoding="utf-8"))
19
+
20
+ agent = data.get("agent", {}) if isinstance(data.get("agent"), dict) else {}
21
+ harness = str(agent.get("harness", "deepagents") or "deepagents")
22
+ sandbox = str(agent.get("sandbox", "virtual") or "virtual")
23
+ skills_dir = agent.get("skills_dir")
24
+ state_dir = agent.get("state_dir")
25
+
26
+ return PyFlueConfig(
27
+ model=agent.get("model"),
28
+ harness=harness,
29
+ sandbox=sandbox,
30
+ root=root,
31
+ skills_dir=(root / skills_dir).resolve() if skills_dir else None,
32
+ state_dir=(root / state_dir).resolve() if state_dir else None,
33
+ harness_config={
34
+ key: value
35
+ for key, value in data.items()
36
+ if key not in {"agent", "deployment"}
37
+ },
38
+ )
pyflue/core.py ADDED
@@ -0,0 +1,216 @@
1
+ """PyFlue core agent and session API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import aiosqlite
12
+ from pydantic import BaseModel, TypeAdapter
13
+
14
+ from pyflue.config import load_config
15
+ from pyflue.harnesses.registry import create_backend
16
+ from pyflue.sandbox import SandboxPolicy, VirtualSandbox
17
+ from pyflue.skills import load_project_instructions, load_skills, render_skill_prompt
18
+ from pyflue.types import HarnessResult, PyFlueConfig
19
+
20
+ RESULT_START = "---RESULT_START---"
21
+ RESULT_END = "---RESULT_END---"
22
+ _RESULT_RE = re.compile(
23
+ rf"{RESULT_START}\s*\n(?P<body>[\s\S]*?)\n?{RESULT_END}",
24
+ re.MULTILINE,
25
+ )
26
+
27
+
28
+ async def init(
29
+ *,
30
+ model: str | None = None,
31
+ harness: str | None = None,
32
+ sandbox: str | None = None,
33
+ skills_dir: str | Path | None = None,
34
+ config_path: str | Path | None = None,
35
+ env: dict[str, str] | None = None,
36
+ allow_write: bool = False,
37
+ allow_shell: bool = False,
38
+ ) -> PyFlueAgent:
39
+ """Initialize a PyFlue agent."""
40
+ config = load_config(config_path or "pyflue.toml")
41
+ if model is not None:
42
+ config.model = model
43
+ if harness is not None:
44
+ config.harness = harness
45
+ if sandbox is not None:
46
+ config.sandbox = sandbox
47
+ if skills_dir is not None:
48
+ path = Path(skills_dir).expanduser()
49
+ config.skills_dir = path if path.is_absolute() else config.root / path
50
+ if env:
51
+ config.env.update({str(key): str(value) for key, value in env.items()})
52
+ return PyFlueAgent(
53
+ config=config,
54
+ sandbox_policy=SandboxPolicy(allow_write=allow_write, allow_shell=allow_shell),
55
+ )
56
+
57
+
58
+ class PyFlueAgent:
59
+ """Factory for stateful PyFlue sessions."""
60
+
61
+ def __init__(
62
+ self,
63
+ *,
64
+ config: PyFlueConfig,
65
+ sandbox_policy: SandboxPolicy | None = None,
66
+ ):
67
+ self.config = config
68
+ self.backend = create_backend(config.harness)
69
+ self.instructions = load_project_instructions(config.root)
70
+ self.skills = load_skills(config.root, config.skills_dir)
71
+ self.sandbox = VirtualSandbox(config.root, policy=sandbox_policy)
72
+ self.state_dir = config.state_dir or config.root / ".pyflue" / "sessions"
73
+ self.state_dir.mkdir(parents=True, exist_ok=True)
74
+
75
+ async def session(self, session_id: str | None = None) -> PyFlueSession:
76
+ """Open or create a persistent session."""
77
+ sid = session_id or "default"
78
+ session = PyFlueSession(agent=self, session_id=sid)
79
+ await session._ensure_store()
80
+ return session
81
+
82
+
83
+ class PyFlueSession:
84
+ """One persistent PyFlue conversation."""
85
+
86
+ def __init__(self, *, agent: PyFlueAgent, session_id: str):
87
+ self.agent = agent
88
+ self.session_id = session_id
89
+ safe_id = re.sub(r"[^a-zA-Z0-9_.-]+", "_", session_id)
90
+ self.db_path = self.agent.state_dir / f"{safe_id}.sqlite3"
91
+
92
+ async def prompt(
93
+ self,
94
+ text: str,
95
+ *,
96
+ result: type[BaseModel] | Any | None = None,
97
+ ) -> HarnessResult | Any:
98
+ """Run one prompt turn."""
99
+ prompt = self._build_prompt(text, result=result)
100
+ await self._append("user", text)
101
+ history = await self._history_prompt(prompt)
102
+ output = await self.agent.backend.run(
103
+ prompt=history,
104
+ system_prompt=self.agent.instructions,
105
+ config=self.agent.config,
106
+ skills=self.agent.skills,
107
+ sandbox=self.agent.sandbox,
108
+ session_id=self.session_id,
109
+ )
110
+ await self._append("assistant", output.text)
111
+ if result is not None:
112
+ return _parse_typed_result(output.text, result)
113
+ return output
114
+
115
+ async def skill(
116
+ self,
117
+ name: str,
118
+ *,
119
+ args: dict[str, Any] | None = None,
120
+ result: type[BaseModel] | Any | None = None,
121
+ ) -> HarnessResult | Any:
122
+ """Run a Markdown-defined skill."""
123
+ skill = self.agent.skills.get(name)
124
+ if skill is None:
125
+ available = ", ".join(sorted(self.agent.skills)) or "(none)"
126
+ raise KeyError(f"Unknown skill '{name}'. Available skills: {available}")
127
+ prompt = render_skill_prompt(skill, args=args)
128
+ return await self.prompt(prompt, result=result)
129
+
130
+ async def subagent(
131
+ self,
132
+ prompt: str,
133
+ *,
134
+ result: type[BaseModel] | Any | None = None,
135
+ ) -> HarnessResult | Any:
136
+ """Run a child session with isolated history and shared sandbox."""
137
+ child_id = f"{self.session_id}:task:{uuid.uuid4().hex[:10]}"
138
+ child = await self.agent.session(child_id)
139
+ output = await child.prompt(prompt, result=result)
140
+ await self._append("assistant", f"Subagent {child_id} completed.")
141
+ return output
142
+
143
+ async def shell(self, command: str, *, timeout: int | None = 120) -> dict[str, Any]:
144
+ """Run a shell command through the configured sandbox."""
145
+ output = await self.agent.backend.shell(
146
+ command,
147
+ sandbox=self.agent.sandbox,
148
+ timeout=timeout,
149
+ )
150
+ await self._append("tool", json.dumps(output, sort_keys=True))
151
+ return output
152
+
153
+ async def read_file(self, path: str) -> str:
154
+ """Read a file from the session sandbox."""
155
+ return self.agent.sandbox.read_file(path)
156
+
157
+ async def write_file(self, path: str, content: str) -> str:
158
+ """Write a file into the session sandbox."""
159
+ return self.agent.sandbox.write_file(path, content)
160
+
161
+ async def _ensure_store(self) -> None:
162
+ async with aiosqlite.connect(self.db_path) as db:
163
+ await db.execute(
164
+ "create table if not exists messages "
165
+ "(id integer primary key autoincrement, role text not null, content text not null)"
166
+ )
167
+ await db.commit()
168
+
169
+ async def _append(self, role: str, content: str) -> None:
170
+ async with aiosqlite.connect(self.db_path) as db:
171
+ await db.execute(
172
+ "insert into messages(role, content) values (?, ?)",
173
+ (role, content),
174
+ )
175
+ await db.commit()
176
+
177
+ async def _messages(self) -> list[tuple[str, str]]:
178
+ async with aiosqlite.connect(self.db_path) as db:
179
+ cursor = await db.execute(
180
+ "select role, content from messages order by id desc limit 12"
181
+ )
182
+ rows = await cursor.fetchall()
183
+ return list(reversed([(str(role), str(content)) for role, content in rows]))
184
+
185
+ async def _history_prompt(self, prompt: str) -> str:
186
+ rows = await self._messages()
187
+ if not rows:
188
+ return prompt
189
+ history = "\n\n".join(f"{role}: {content}" for role, content in rows)
190
+ return f"Conversation so far:\n{history}\n\nNext:\n{prompt}"
191
+
192
+ def _build_prompt(self, text: str, *, result: Any | None = None) -> str:
193
+ parts = [
194
+ "You are running inside PyFlue, a headless Python agent harness.",
195
+ text.strip(),
196
+ ]
197
+ if result is not None:
198
+ schema = TypeAdapter(result).json_schema()
199
+ parts.extend(
200
+ [
201
+ "Return the final structured result between these exact delimiters:",
202
+ RESULT_START,
203
+ json.dumps(schema, indent=2, sort_keys=True),
204
+ RESULT_END,
205
+ ]
206
+ )
207
+ return "\n\n".join(parts)
208
+
209
+
210
+ def _parse_typed_result(text: str, result: Any) -> Any:
211
+ matches = list(_RESULT_RE.finditer(text or ""))
212
+ raw = matches[-1].group("body").strip() if matches else text.strip()
213
+ value: Any = raw
214
+ if raw.startswith("{") or raw.startswith("["):
215
+ value = json.loads(raw)
216
+ return TypeAdapter(result).validate_python(value)
@@ -0,0 +1,6 @@
1
+ """PyFlue harness backends."""
2
+
3
+ from pyflue.harnesses.base import HarnessBackend
4
+ from pyflue.harnesses.registry import create_backend, register_harness
5
+
6
+ __all__ = ["HarnessBackend", "create_backend", "register_harness"]
@@ -0,0 +1,31 @@
1
+ """Harness backend interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from pyflue.types import HarnessResult, PyFlueConfig, Skill
9
+
10
+
11
+ class HarnessBackend(ABC):
12
+ """Backend contract implemented by all harness integrations."""
13
+
14
+ name: str
15
+
16
+ @abstractmethod
17
+ async def run(
18
+ self,
19
+ *,
20
+ prompt: str,
21
+ system_prompt: str,
22
+ config: PyFlueConfig,
23
+ skills: dict[str, Skill],
24
+ sandbox: Any,
25
+ session_id: str,
26
+ ) -> HarnessResult:
27
+ """Run one prompt turn."""
28
+
29
+ async def shell(self, command: str, *, sandbox: Any, timeout: int | None = None) -> dict[str, Any]:
30
+ """Run a shell command through the configured sandbox."""
31
+ return sandbox.shell(command, timeout=timeout)