agentic-planning 0.1.0__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.
- agentic_planning-0.1.0/.gitignore +92 -0
- agentic_planning-0.1.0/PKG-INFO +182 -0
- agentic_planning-0.1.0/README.md +157 -0
- agentic_planning-0.1.0/pyproject.toml +42 -0
- agentic_planning-0.1.0/src/agentic_planning/__init__.py +54 -0
- agentic_planning-0.1.0/tests/__init__.py +0 -0
- agentic_planning-0.1.0/tests/test_planning.py +337 -0
- agentic_planning-0.1.0/tests/test_planning_graph.py +21 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
target/
|
|
3
|
+
*.swp
|
|
4
|
+
*.swo
|
|
5
|
+
|
|
6
|
+
# Python
|
|
7
|
+
__pycache__/
|
|
8
|
+
*.py[cod]
|
|
9
|
+
*$py.class
|
|
10
|
+
*.egg-info/
|
|
11
|
+
*.egg
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
.eggs/
|
|
15
|
+
*.whl
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
env/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
*.iml
|
|
24
|
+
.fleet/
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
*.swp
|
|
30
|
+
*~
|
|
31
|
+
|
|
32
|
+
# Testing
|
|
33
|
+
.pytest_cache/
|
|
34
|
+
.coverage
|
|
35
|
+
htmlcov/
|
|
36
|
+
.mypy_cache/
|
|
37
|
+
.tox/
|
|
38
|
+
|
|
39
|
+
# LaTeX build artifacts
|
|
40
|
+
*.aux
|
|
41
|
+
*.bbl
|
|
42
|
+
*.blg
|
|
43
|
+
*.log
|
|
44
|
+
*.out
|
|
45
|
+
*.toc
|
|
46
|
+
*.fls
|
|
47
|
+
*.fdb_latexmk
|
|
48
|
+
*.synctex.gz
|
|
49
|
+
|
|
50
|
+
# Planning docs / AI prompts (internal only)
|
|
51
|
+
planning-docs/
|
|
52
|
+
CLAUDE-CODE-INSTRUCTIONS*.md
|
|
53
|
+
SPEC-*.md
|
|
54
|
+
|
|
55
|
+
# Internal vision / roadmap documents
|
|
56
|
+
docs/VISION-*.md
|
|
57
|
+
|
|
58
|
+
# Internal specs
|
|
59
|
+
specs/
|
|
60
|
+
|
|
61
|
+
# MCP crate internal docs / specs / AI prompts
|
|
62
|
+
crates/agentic-planning-mcp/docs/
|
|
63
|
+
crates/agentic-planning-mcp/scripts/
|
|
64
|
+
|
|
65
|
+
# Claude Code
|
|
66
|
+
.claude/
|
|
67
|
+
.agentra/
|
|
68
|
+
docs/REPO_HYGIENE.md
|
|
69
|
+
|
|
70
|
+
# Root-level paper files (canonical copies are in paper/)
|
|
71
|
+
/agenticplanning-paper.*
|
|
72
|
+
/references.bib
|
|
73
|
+
|
|
74
|
+
# FFI (build artifacts)
|
|
75
|
+
/ffi/
|
|
76
|
+
|
|
77
|
+
# Scripts (internal tooling)
|
|
78
|
+
/agent/scripts/
|
|
79
|
+
/installer/scripts/
|
|
80
|
+
|
|
81
|
+
# Internal docs (not for public repo)
|
|
82
|
+
docs/internal/
|
|
83
|
+
ECOSYSTEM-CONVENTIONS.md
|
|
84
|
+
|
|
85
|
+
# Local goals (internal only)
|
|
86
|
+
goals/
|
|
87
|
+
|
|
88
|
+
# Environment
|
|
89
|
+
.env
|
|
90
|
+
.env.local
|
|
91
|
+
*.pem
|
|
92
|
+
*.key
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentic-planning
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Planning infrastructure for AI agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentralabs/agentic-planning
|
|
6
|
+
Project-URL: Documentation, https://github.com/agentralabs/agentic-planning/tree/main/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/agentralabs/agentic-planning
|
|
8
|
+
Author: Agentra Labs
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: agents,ai,mcp,planning
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# AgenticPlanning Python SDK
|
|
27
|
+
|
|
28
|
+
Thin Python wrapper around the `aplan` FFI library via `ctypes`.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install agentic-planning
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Requires the `aplan` shared library (`.so`/`.dylib`/`.dll`) on your library path, or the `aplan` binary on PATH for CLI-mode fallback.
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from agentic_planning import PlanningGraph
|
|
42
|
+
|
|
43
|
+
graph = PlanningGraph("project.aplan")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Goals
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Create a goal
|
|
50
|
+
goal = graph.create_goal("Ship v1", intention="Persistent intention infrastructure")
|
|
51
|
+
print(goal["id"])
|
|
52
|
+
|
|
53
|
+
# List all goals
|
|
54
|
+
goals = graph.list_goals()
|
|
55
|
+
for g in goals:
|
|
56
|
+
print(f"{g['title']} — {g['status']} (momentum: {g['momentum']:.2f})")
|
|
57
|
+
|
|
58
|
+
# Update goal status
|
|
59
|
+
graph.activate_goal(goal["id"])
|
|
60
|
+
graph.complete_goal(goal["id"])
|
|
61
|
+
|
|
62
|
+
# Record progress
|
|
63
|
+
graph.record_progress(goal["id"], percentage=45, note="API endpoints done")
|
|
64
|
+
|
|
65
|
+
# Get goal with computed feelings
|
|
66
|
+
detail = graph.get_goal(goal["id"])
|
|
67
|
+
print(f"Urgency: {detail['feelings']['urgency']:.2f}")
|
|
68
|
+
print(f"Neglect: {detail['feelings']['neglect']:.2f}")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Decisions
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Create a decision linked to a goal
|
|
75
|
+
decision = graph.create_decision(
|
|
76
|
+
title="Use PostgreSQL vs SQLite",
|
|
77
|
+
goal_id=goal["id"],
|
|
78
|
+
options=["PostgreSQL", "SQLite", "Both with abstraction layer"]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Crystallize (choose an option)
|
|
82
|
+
graph.crystallize_decision(
|
|
83
|
+
decision["id"],
|
|
84
|
+
chosen="SQLite",
|
|
85
|
+
reasoning="Simpler deployment, sufficient for planning state sizes"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Query past decisions
|
|
89
|
+
history = graph.list_decisions(goal_id=goal["id"])
|
|
90
|
+
for d in history:
|
|
91
|
+
print(f"{d['title']} — {d['status']}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Commitments
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
# Create a commitment
|
|
98
|
+
commitment = graph.create_commitment(
|
|
99
|
+
title="Deliver API docs by Friday",
|
|
100
|
+
stakeholder="team-lead",
|
|
101
|
+
stakeholder_weight=0.8,
|
|
102
|
+
due_date="2026-03-07T17:00:00Z"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Check at-risk commitments
|
|
106
|
+
at_risk = graph.list_commitments(status="at_risk")
|
|
107
|
+
for c in at_risk:
|
|
108
|
+
print(f"AT RISK: {c['title']} (due: {c['due_date']})")
|
|
109
|
+
|
|
110
|
+
# Fulfill or break
|
|
111
|
+
graph.fulfill_commitment(commitment["id"])
|
|
112
|
+
# graph.break_commitment(commitment["id"], reason="Requirements changed")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Singularity
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# Get the unified field view of all goals
|
|
119
|
+
singularity = graph.get_singularity()
|
|
120
|
+
print(f"Center: {singularity['center']}")
|
|
121
|
+
print(f"Themes: {singularity['themes']}")
|
|
122
|
+
print(f"Golden path: {singularity['golden_path']}")
|
|
123
|
+
|
|
124
|
+
# Check for tensions between goals
|
|
125
|
+
for tension in singularity["tensions"]:
|
|
126
|
+
print(f"Tension: {tension['goal_a']} <-> {tension['goal_b']}")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Blockers and Prophecy
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# Scan for blocked goals
|
|
133
|
+
blockers = graph.scan_blockers()
|
|
134
|
+
for b in blockers:
|
|
135
|
+
print(f"{b['goal_title']} blocked by: {b['blocker_description']}")
|
|
136
|
+
|
|
137
|
+
# Listen for progress echoes
|
|
138
|
+
echoes = graph.listen_echoes()
|
|
139
|
+
for echo in echoes:
|
|
140
|
+
print(f"Echo: {echo['source_goal']} -> {echo['affected_goal']}: {echo['effect']}")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## File Persistence
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
# Save current state
|
|
147
|
+
graph.save()
|
|
148
|
+
|
|
149
|
+
# Load from file
|
|
150
|
+
graph = PlanningGraph("project.aplan")
|
|
151
|
+
|
|
152
|
+
# The .aplan file is portable — copy it anywhere
|
|
153
|
+
import shutil
|
|
154
|
+
shutil.copy("project.aplan", "/backup/project.aplan")
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Error Handling
|
|
158
|
+
|
|
159
|
+
All methods raise `PlanningError` on failure:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
from agentic_planning import PlanningError
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
graph.complete_goal("nonexistent-id")
|
|
166
|
+
except PlanningError as e:
|
|
167
|
+
print(f"Error code: {e.code}") # e.g., 4 (NotFound)
|
|
168
|
+
print(f"Message: {e.message}")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Error codes match the FFI `AplanResult` enum:
|
|
172
|
+
|
|
173
|
+
| Code | Name | Meaning |
|
|
174
|
+
|------|------|---------|
|
|
175
|
+
| 0 | Ok | Success |
|
|
176
|
+
| 1 | NullPointer | Internal null pointer |
|
|
177
|
+
| 2 | InvalidUtf8 | Bad string encoding |
|
|
178
|
+
| 3 | EngineError | Engine-level failure |
|
|
179
|
+
| 4 | NotFound | Entity doesn't exist |
|
|
180
|
+
| 5 | ValidationError | Invalid input |
|
|
181
|
+
| 6 | IoError | File I/O failure |
|
|
182
|
+
| 7 | SerializationError | JSON error |
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# AgenticPlanning Python SDK
|
|
2
|
+
|
|
3
|
+
Thin Python wrapper around the `aplan` FFI library via `ctypes`.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agentic-planning
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires the `aplan` shared library (`.so`/`.dylib`/`.dll`) on your library path, or the `aplan` binary on PATH for CLI-mode fallback.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from agentic_planning import PlanningGraph
|
|
17
|
+
|
|
18
|
+
graph = PlanningGraph("project.aplan")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
# Create a goal
|
|
25
|
+
goal = graph.create_goal("Ship v1", intention="Persistent intention infrastructure")
|
|
26
|
+
print(goal["id"])
|
|
27
|
+
|
|
28
|
+
# List all goals
|
|
29
|
+
goals = graph.list_goals()
|
|
30
|
+
for g in goals:
|
|
31
|
+
print(f"{g['title']} — {g['status']} (momentum: {g['momentum']:.2f})")
|
|
32
|
+
|
|
33
|
+
# Update goal status
|
|
34
|
+
graph.activate_goal(goal["id"])
|
|
35
|
+
graph.complete_goal(goal["id"])
|
|
36
|
+
|
|
37
|
+
# Record progress
|
|
38
|
+
graph.record_progress(goal["id"], percentage=45, note="API endpoints done")
|
|
39
|
+
|
|
40
|
+
# Get goal with computed feelings
|
|
41
|
+
detail = graph.get_goal(goal["id"])
|
|
42
|
+
print(f"Urgency: {detail['feelings']['urgency']:.2f}")
|
|
43
|
+
print(f"Neglect: {detail['feelings']['neglect']:.2f}")
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Decisions
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
# Create a decision linked to a goal
|
|
50
|
+
decision = graph.create_decision(
|
|
51
|
+
title="Use PostgreSQL vs SQLite",
|
|
52
|
+
goal_id=goal["id"],
|
|
53
|
+
options=["PostgreSQL", "SQLite", "Both with abstraction layer"]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Crystallize (choose an option)
|
|
57
|
+
graph.crystallize_decision(
|
|
58
|
+
decision["id"],
|
|
59
|
+
chosen="SQLite",
|
|
60
|
+
reasoning="Simpler deployment, sufficient for planning state sizes"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Query past decisions
|
|
64
|
+
history = graph.list_decisions(goal_id=goal["id"])
|
|
65
|
+
for d in history:
|
|
66
|
+
print(f"{d['title']} — {d['status']}")
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Commitments
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
# Create a commitment
|
|
73
|
+
commitment = graph.create_commitment(
|
|
74
|
+
title="Deliver API docs by Friday",
|
|
75
|
+
stakeholder="team-lead",
|
|
76
|
+
stakeholder_weight=0.8,
|
|
77
|
+
due_date="2026-03-07T17:00:00Z"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Check at-risk commitments
|
|
81
|
+
at_risk = graph.list_commitments(status="at_risk")
|
|
82
|
+
for c in at_risk:
|
|
83
|
+
print(f"AT RISK: {c['title']} (due: {c['due_date']})")
|
|
84
|
+
|
|
85
|
+
# Fulfill or break
|
|
86
|
+
graph.fulfill_commitment(commitment["id"])
|
|
87
|
+
# graph.break_commitment(commitment["id"], reason="Requirements changed")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Singularity
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
# Get the unified field view of all goals
|
|
94
|
+
singularity = graph.get_singularity()
|
|
95
|
+
print(f"Center: {singularity['center']}")
|
|
96
|
+
print(f"Themes: {singularity['themes']}")
|
|
97
|
+
print(f"Golden path: {singularity['golden_path']}")
|
|
98
|
+
|
|
99
|
+
# Check for tensions between goals
|
|
100
|
+
for tension in singularity["tensions"]:
|
|
101
|
+
print(f"Tension: {tension['goal_a']} <-> {tension['goal_b']}")
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Blockers and Prophecy
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
# Scan for blocked goals
|
|
108
|
+
blockers = graph.scan_blockers()
|
|
109
|
+
for b in blockers:
|
|
110
|
+
print(f"{b['goal_title']} blocked by: {b['blocker_description']}")
|
|
111
|
+
|
|
112
|
+
# Listen for progress echoes
|
|
113
|
+
echoes = graph.listen_echoes()
|
|
114
|
+
for echo in echoes:
|
|
115
|
+
print(f"Echo: {echo['source_goal']} -> {echo['affected_goal']}: {echo['effect']}")
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## File Persistence
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# Save current state
|
|
122
|
+
graph.save()
|
|
123
|
+
|
|
124
|
+
# Load from file
|
|
125
|
+
graph = PlanningGraph("project.aplan")
|
|
126
|
+
|
|
127
|
+
# The .aplan file is portable — copy it anywhere
|
|
128
|
+
import shutil
|
|
129
|
+
shutil.copy("project.aplan", "/backup/project.aplan")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Error Handling
|
|
133
|
+
|
|
134
|
+
All methods raise `PlanningError` on failure:
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
from agentic_planning import PlanningError
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
graph.complete_goal("nonexistent-id")
|
|
141
|
+
except PlanningError as e:
|
|
142
|
+
print(f"Error code: {e.code}") # e.g., 4 (NotFound)
|
|
143
|
+
print(f"Message: {e.message}")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Error codes match the FFI `AplanResult` enum:
|
|
147
|
+
|
|
148
|
+
| Code | Name | Meaning |
|
|
149
|
+
|------|------|---------|
|
|
150
|
+
| 0 | Ok | Success |
|
|
151
|
+
| 1 | NullPointer | Internal null pointer |
|
|
152
|
+
| 2 | InvalidUtf8 | Bad string encoding |
|
|
153
|
+
| 3 | EngineError | Engine-level failure |
|
|
154
|
+
| 4 | NotFound | Entity doesn't exist |
|
|
155
|
+
| 5 | ValidationError | Invalid input |
|
|
156
|
+
| 6 | IoError | File I/O failure |
|
|
157
|
+
| 7 | SerializationError | JSON error |
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agentic-planning"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Planning infrastructure for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Agentra Labs" }]
|
|
13
|
+
keywords = ["ai", "agents", "planning", "mcp"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence"
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=8.0", "pytest-cov>=5.0", "mypy>=1.10"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/agentralabs/agentic-planning"
|
|
31
|
+
Documentation = "https://github.com/agentralabs/agentic-planning/tree/main/docs"
|
|
32
|
+
Repository = "https://github.com/agentralabs/agentic-planning"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/agentic_planning"]
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.mypy]
|
|
41
|
+
python_version = "3.10"
|
|
42
|
+
strict = true
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""AgenticPlanning Python SDK.
|
|
2
|
+
|
|
3
|
+
Wraps the `aplan` CLI for lightweight scripting.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlanningError(Exception):
|
|
19
|
+
"""Raised when an `aplan` command fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PlanningGraph:
|
|
24
|
+
path: str | Path
|
|
25
|
+
binary: str = "aplan"
|
|
26
|
+
|
|
27
|
+
def __post_init__(self) -> None:
|
|
28
|
+
self.path = Path(self.path)
|
|
29
|
+
|
|
30
|
+
def _binary(self) -> str:
|
|
31
|
+
found = shutil.which(self.binary)
|
|
32
|
+
if not found:
|
|
33
|
+
raise PlanningError(
|
|
34
|
+
f"Cannot find '{self.binary}' on PATH. Install via https://agentralabs.tech/install/planning"
|
|
35
|
+
)
|
|
36
|
+
return found
|
|
37
|
+
|
|
38
|
+
def _run_json(self, *args: str) -> Any:
|
|
39
|
+
cmd = [self._binary(), "--file", str(self.path), *args]
|
|
40
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
raise PlanningError(result.stderr.strip() or "aplan command failed")
|
|
43
|
+
raw = result.stdout.strip()
|
|
44
|
+
return json.loads(raw) if raw else {}
|
|
45
|
+
|
|
46
|
+
def create_goal(self, title: str, intention: str) -> Any:
|
|
47
|
+
return self._run_json("goal", "create", title, "--intention", intention)
|
|
48
|
+
|
|
49
|
+
def list_goals(self) -> Any:
|
|
50
|
+
return self._run_json("goal", "list")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def exists(self) -> bool:
|
|
54
|
+
return self.path.exists()
|
|
File without changes
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Comprehensive tests for AgenticPlanning Python SDK.
|
|
2
|
+
|
|
3
|
+
This file tests PlanningGraph and PlanningError.
|
|
4
|
+
Does NOT replace test_planning_graph.py -- this is an additional test file.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from unittest.mock import patch, MagicMock
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from agentic_planning import PlanningGraph, PlanningError, __version__
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# 1. Package Metadata
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestPackageMetadata:
|
|
24
|
+
def test_version_exists(self) -> None:
|
|
25
|
+
assert __version__ is not None
|
|
26
|
+
assert isinstance(__version__, str)
|
|
27
|
+
assert len(__version__) > 0
|
|
28
|
+
|
|
29
|
+
def test_version_semver(self) -> None:
|
|
30
|
+
parts = __version__.split(".")
|
|
31
|
+
assert len(parts) == 3
|
|
32
|
+
assert all(p.isdigit() for p in parts)
|
|
33
|
+
|
|
34
|
+
def test_version_is_010(self) -> None:
|
|
35
|
+
assert __version__ == "0.1.0"
|
|
36
|
+
|
|
37
|
+
def test_import_main_class(self) -> None:
|
|
38
|
+
assert PlanningGraph is not None
|
|
39
|
+
|
|
40
|
+
def test_import_error_class(self) -> None:
|
|
41
|
+
assert PlanningError is not None
|
|
42
|
+
assert issubclass(PlanningError, Exception)
|
|
43
|
+
|
|
44
|
+
def test_main_class_has_docstring(self) -> None:
|
|
45
|
+
# PlanningGraph is a dataclass; it may not have a long docstring
|
|
46
|
+
# but it should at least be importable and functional
|
|
47
|
+
assert PlanningGraph is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# 2. Initialization
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestInit:
|
|
56
|
+
def test_create_with_string_path(self, tmp_path: Path) -> None:
|
|
57
|
+
path = str(tmp_path / "test.aplan")
|
|
58
|
+
obj = PlanningGraph(path)
|
|
59
|
+
assert str(obj.path) == path
|
|
60
|
+
|
|
61
|
+
def test_create_with_path_object(self, tmp_path: Path) -> None:
|
|
62
|
+
path = tmp_path / "test.aplan"
|
|
63
|
+
obj = PlanningGraph(path)
|
|
64
|
+
assert obj.path == path
|
|
65
|
+
|
|
66
|
+
def test_create_with_pure_posix_path(self) -> None:
|
|
67
|
+
obj = PlanningGraph(PurePosixPath("/tmp/test.aplan"))
|
|
68
|
+
assert "test.aplan" in str(obj.path)
|
|
69
|
+
|
|
70
|
+
def test_path_converted_to_path_object(self, tmp_path: Path) -> None:
|
|
71
|
+
path = str(tmp_path / "test.aplan")
|
|
72
|
+
obj = PlanningGraph(path)
|
|
73
|
+
assert isinstance(obj.path, Path)
|
|
74
|
+
|
|
75
|
+
def test_custom_binary_name(self, tmp_path: Path) -> None:
|
|
76
|
+
obj = PlanningGraph(str(tmp_path / "test.aplan"), binary="custom-bin")
|
|
77
|
+
assert obj.binary == "custom-bin"
|
|
78
|
+
|
|
79
|
+
def test_default_binary_name(self, tmp_path: Path) -> None:
|
|
80
|
+
obj = PlanningGraph(str(tmp_path / "test.aplan"))
|
|
81
|
+
assert obj.binary == "aplan"
|
|
82
|
+
|
|
83
|
+
def test_exists_false_for_new(self, tmp_path: Path) -> None:
|
|
84
|
+
obj = PlanningGraph(str(tmp_path / "nonexistent.aplan"))
|
|
85
|
+
assert not obj.exists
|
|
86
|
+
|
|
87
|
+
def test_exists_true_when_file_present(self, tmp_path: Path) -> None:
|
|
88
|
+
path = tmp_path / "exists.aplan"
|
|
89
|
+
path.touch()
|
|
90
|
+
obj = PlanningGraph(str(path))
|
|
91
|
+
assert obj.exists
|
|
92
|
+
|
|
93
|
+
def test_repr_does_not_crash(self, tmp_path: Path) -> None:
|
|
94
|
+
obj = PlanningGraph(str(tmp_path / "test.aplan"))
|
|
95
|
+
r = repr(obj)
|
|
96
|
+
assert isinstance(r, str)
|
|
97
|
+
|
|
98
|
+
def test_path_name_preserved(self, tmp_path: Path) -> None:
|
|
99
|
+
obj = PlanningGraph(str(tmp_path / "test.aplan"))
|
|
100
|
+
assert obj.path.name == "test.aplan"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# 3. Binary Resolution
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TestBinaryResolution:
|
|
109
|
+
def test_missing_binary_raises(self, tmp_path: Path) -> None:
|
|
110
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
|
|
111
|
+
with pytest.raises(PlanningError):
|
|
112
|
+
obj._binary()
|
|
113
|
+
|
|
114
|
+
def test_error_contains_binary_name(self, tmp_path: Path) -> None:
|
|
115
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
|
|
116
|
+
with pytest.raises(PlanningError, match="nonexistent-xyz-999"):
|
|
117
|
+
obj._binary()
|
|
118
|
+
|
|
119
|
+
def test_error_contains_install_hint(self, tmp_path: Path) -> None:
|
|
120
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"), binary="nonexistent-xyz-999")
|
|
121
|
+
with pytest.raises(PlanningError, match="install"):
|
|
122
|
+
obj._binary()
|
|
123
|
+
|
|
124
|
+
def test_binary_returns_string(self, tmp_path: Path) -> None:
|
|
125
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
126
|
+
with patch("shutil.which", return_value="/usr/bin/aplan"):
|
|
127
|
+
result = obj._binary()
|
|
128
|
+
assert isinstance(result, str)
|
|
129
|
+
assert result == "/usr/bin/aplan"
|
|
130
|
+
|
|
131
|
+
def test_binary_uses_shutil_which(self, tmp_path: Path) -> None:
|
|
132
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
133
|
+
with patch("shutil.which", return_value="/opt/bin/aplan") as mock_which:
|
|
134
|
+
result = obj._binary()
|
|
135
|
+
mock_which.assert_called_with("aplan")
|
|
136
|
+
assert result == "/opt/bin/aplan"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# 4. Subprocess Execution
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestSubprocessExecution:
|
|
145
|
+
def test_run_json_calls_subprocess(self, tmp_path: Path) -> None:
|
|
146
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
147
|
+
with patch("shutil.which", return_value="/usr/bin/echo"), \
|
|
148
|
+
patch("subprocess.run") as mock_run:
|
|
149
|
+
mock_run.return_value = MagicMock(
|
|
150
|
+
returncode=0, stdout='{"key": "value"}\n', stderr=""
|
|
151
|
+
)
|
|
152
|
+
result = obj._run_json("test")
|
|
153
|
+
assert mock_run.called
|
|
154
|
+
cmd = mock_run.call_args[0][0]
|
|
155
|
+
assert cmd[0] == "/usr/bin/echo"
|
|
156
|
+
|
|
157
|
+
def test_run_json_includes_file_flag(self, tmp_path: Path) -> None:
|
|
158
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
159
|
+
with patch("shutil.which", return_value="/usr/bin/echo"), \
|
|
160
|
+
patch("subprocess.run") as mock_run:
|
|
161
|
+
mock_run.return_value = MagicMock(
|
|
162
|
+
returncode=0, stdout='{"k": 1}\n', stderr=""
|
|
163
|
+
)
|
|
164
|
+
obj._run_json("test")
|
|
165
|
+
cmd = mock_run.call_args[0][0]
|
|
166
|
+
assert "--file" in cmd
|
|
167
|
+
assert str(tmp_path / "t.aplan") in cmd
|
|
168
|
+
|
|
169
|
+
def test_run_json_raises_on_nonzero_exit(self, tmp_path: Path) -> None:
|
|
170
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
171
|
+
with patch("shutil.which", return_value="/bin/false"), \
|
|
172
|
+
patch("subprocess.run") as mock_run:
|
|
173
|
+
mock_run.return_value = MagicMock(
|
|
174
|
+
returncode=1, stdout="", stderr="error happened"
|
|
175
|
+
)
|
|
176
|
+
with pytest.raises(PlanningError, match="error happened"):
|
|
177
|
+
obj._run_json("fail")
|
|
178
|
+
|
|
179
|
+
def test_run_json_parses_output(self, tmp_path: Path) -> None:
|
|
180
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
181
|
+
with patch("shutil.which", return_value="/usr/bin/echo"), \
|
|
182
|
+
patch("subprocess.run") as mock_run:
|
|
183
|
+
mock_run.return_value = MagicMock(
|
|
184
|
+
returncode=0, stdout='{"key": "value"}\n', stderr=""
|
|
185
|
+
)
|
|
186
|
+
result = obj._run_json("test")
|
|
187
|
+
assert result == {"key": "value"}
|
|
188
|
+
|
|
189
|
+
def test_run_json_returns_empty_dict_on_empty_output(self, tmp_path: Path) -> None:
|
|
190
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
191
|
+
with patch("shutil.which", return_value="/usr/bin/echo"), \
|
|
192
|
+
patch("subprocess.run") as mock_run:
|
|
193
|
+
mock_run.return_value = MagicMock(
|
|
194
|
+
returncode=0, stdout="", stderr=""
|
|
195
|
+
)
|
|
196
|
+
result = obj._run_json("test")
|
|
197
|
+
assert result == {}
|
|
198
|
+
|
|
199
|
+
def test_run_json_raises_on_invalid_json(self, tmp_path: Path) -> None:
|
|
200
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
201
|
+
with patch("shutil.which", return_value="/usr/bin/echo"), \
|
|
202
|
+
patch("subprocess.run") as mock_run:
|
|
203
|
+
mock_run.return_value = MagicMock(
|
|
204
|
+
returncode=0, stdout="not json at all", stderr=""
|
|
205
|
+
)
|
|
206
|
+
with pytest.raises((json.JSONDecodeError, PlanningError)):
|
|
207
|
+
obj._run_json("test")
|
|
208
|
+
|
|
209
|
+
def test_run_json_error_with_empty_stderr_uses_fallback(self, tmp_path: Path) -> None:
|
|
210
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
211
|
+
with patch("shutil.which", return_value="/bin/false"), \
|
|
212
|
+
patch("subprocess.run") as mock_run:
|
|
213
|
+
mock_run.return_value = MagicMock(
|
|
214
|
+
returncode=1, stdout="", stderr=""
|
|
215
|
+
)
|
|
216
|
+
with pytest.raises(PlanningError, match="aplan command failed"):
|
|
217
|
+
obj._run_json("fail")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# 5. Edge Cases
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class TestEdgeCases:
|
|
226
|
+
def test_empty_path(self) -> None:
|
|
227
|
+
obj = PlanningGraph("")
|
|
228
|
+
assert isinstance(obj.path, Path)
|
|
229
|
+
|
|
230
|
+
def test_path_with_spaces(self, tmp_path: Path) -> None:
|
|
231
|
+
path = tmp_path / "path with spaces" / "test.aplan"
|
|
232
|
+
obj = PlanningGraph(str(path))
|
|
233
|
+
assert "spaces" in str(obj.path)
|
|
234
|
+
|
|
235
|
+
def test_path_with_unicode(self, tmp_path: Path) -> None:
|
|
236
|
+
path = tmp_path / "donnees" / "test.aplan"
|
|
237
|
+
obj = PlanningGraph(str(path))
|
|
238
|
+
assert "donnees" in str(obj.path)
|
|
239
|
+
|
|
240
|
+
def test_very_long_path(self, tmp_path: Path) -> None:
|
|
241
|
+
long_name = "a" * 200
|
|
242
|
+
path = tmp_path / long_name / "test.aplan"
|
|
243
|
+
obj = PlanningGraph(str(path))
|
|
244
|
+
assert len(str(obj.path)) > 200
|
|
245
|
+
|
|
246
|
+
def test_multiple_instances_independent(self, tmp_path: Path) -> None:
|
|
247
|
+
a = PlanningGraph(str(tmp_path / "a.aplan"))
|
|
248
|
+
b = PlanningGraph(str(tmp_path / "b.aplan"))
|
|
249
|
+
assert a.path != b.path
|
|
250
|
+
|
|
251
|
+
def test_dot_in_directory_name(self, tmp_path: Path) -> None:
|
|
252
|
+
path = tmp_path / "v1.0.0" / "test.aplan"
|
|
253
|
+
obj = PlanningGraph(str(path))
|
|
254
|
+
assert "v1.0.0" in str(obj.path)
|
|
255
|
+
|
|
256
|
+
def test_binary_not_cached_across_calls(self, tmp_path: Path) -> None:
|
|
257
|
+
"""PlanningGraph._binary() re-calls shutil.which every time (no caching)."""
|
|
258
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
259
|
+
with patch("shutil.which", return_value="/usr/bin/aplan") as mock_which:
|
|
260
|
+
obj._binary()
|
|
261
|
+
obj._binary()
|
|
262
|
+
assert mock_which.call_count == 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# 6. Error Handling
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class TestErrorHandling:
|
|
271
|
+
def test_error_is_exception(self) -> None:
|
|
272
|
+
assert issubclass(PlanningError, Exception)
|
|
273
|
+
|
|
274
|
+
def test_error_stores_message(self) -> None:
|
|
275
|
+
err = PlanningError("test message")
|
|
276
|
+
assert "test message" in str(err)
|
|
277
|
+
|
|
278
|
+
def test_error_caught_as_exception(self) -> None:
|
|
279
|
+
with pytest.raises(Exception):
|
|
280
|
+
raise PlanningError("boom")
|
|
281
|
+
|
|
282
|
+
def test_error_caught_specifically(self) -> None:
|
|
283
|
+
try:
|
|
284
|
+
raise PlanningError("specific")
|
|
285
|
+
except PlanningError as e:
|
|
286
|
+
assert "specific" in str(e)
|
|
287
|
+
|
|
288
|
+
def test_error_repr(self) -> None:
|
|
289
|
+
err = PlanningError("repr test")
|
|
290
|
+
assert repr(err) is not None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# 7. High-Level API Methods
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class TestAPIMethods:
|
|
299
|
+
def test_create_goal_calls_run_json(self, tmp_path: Path) -> None:
|
|
300
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
301
|
+
with patch.object(obj, "_run_json", return_value={"id": "g1"}) as mock:
|
|
302
|
+
result = obj.create_goal("Build MVP", "ship by Friday")
|
|
303
|
+
mock.assert_called_once_with("goal", "create", "Build MVP", "--intention", "ship by Friday")
|
|
304
|
+
assert result == {"id": "g1"}
|
|
305
|
+
|
|
306
|
+
def test_list_goals_calls_run_json(self, tmp_path: Path) -> None:
|
|
307
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
308
|
+
with patch.object(obj, "_run_json", return_value=[{"id": "g1"}]) as mock:
|
|
309
|
+
result = obj.list_goals()
|
|
310
|
+
mock.assert_called_once_with("goal", "list")
|
|
311
|
+
assert result == [{"id": "g1"}]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---------------------------------------------------------------------------
|
|
315
|
+
# 8. Stress Tests
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class TestStress:
|
|
320
|
+
def test_create_1000_instances(self, tmp_path: Path) -> None:
|
|
321
|
+
instances = [
|
|
322
|
+
PlanningGraph(str(tmp_path / f"test_{i}.aplan"))
|
|
323
|
+
for i in range(1000)
|
|
324
|
+
]
|
|
325
|
+
assert len(instances) == 1000
|
|
326
|
+
assert instances[0].path != instances[999].path
|
|
327
|
+
|
|
328
|
+
def test_binary_lookup_1000_times(self, tmp_path: Path) -> None:
|
|
329
|
+
obj = PlanningGraph(str(tmp_path / "t.aplan"))
|
|
330
|
+
with patch("shutil.which", return_value="/usr/bin/aplan"):
|
|
331
|
+
for _ in range(1000):
|
|
332
|
+
assert obj._binary() == "/usr/bin/aplan"
|
|
333
|
+
|
|
334
|
+
def test_create_1000_errors(self) -> None:
|
|
335
|
+
errors = [PlanningError(f"err_{i}") for i in range(1000)]
|
|
336
|
+
assert len(errors) == 1000
|
|
337
|
+
assert "err_999" in str(errors[999])
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from agentic_planning import PlanningError, PlanningGraph, __version__
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_version_semver() -> None:
|
|
9
|
+
parts = __version__.split(".")
|
|
10
|
+
assert len(parts) == 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_graph_init(tmp_path: pytest.TempPathFactory) -> None:
|
|
14
|
+
graph = PlanningGraph(str(tmp_path / "test.aplan"))
|
|
15
|
+
assert graph.path.name == "test.aplan"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_missing_binary_raises(tmp_path: pytest.TempPathFactory) -> None:
|
|
19
|
+
graph = PlanningGraph(str(tmp_path / "test.aplan"), binary="nonexistent-aplan-binary")
|
|
20
|
+
with pytest.raises(PlanningError):
|
|
21
|
+
graph._binary()
|