freesolo-agent 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.
- client/__init__.py +1 -0
- client/auth.py +24 -0
- client/datasets.py +27 -0
- client/jobs.py +232 -0
- commands/__init__.py +1 -0
- commands/main.py +94 -0
- commands/setup.py +190 -0
- config.py +17 -0
- core/__init__.py +1 -0
- core/output.py +15 -0
- core/types.py +47 -0
- core/validation.py +219 -0
- freesolo_agent-0.1.0.dist-info/METADATA +56 -0
- freesolo_agent-0.1.0.dist-info/RECORD +17 -0
- freesolo_agent-0.1.0.dist-info/WHEEL +5 -0
- freesolo_agent-0.1.0.dist-info/entry_points.txt +2 -0
- freesolo_agent-0.1.0.dist-info/top_level.txt +4 -0
client/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Backend API clients used by the public CLI."""
|
client/auth.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from freesolo.utils.storage import FreesoloStorageClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def require_freesolo_api_key() -> None:
|
|
10
|
+
value = os.getenv("FREESOLO_API_KEY")
|
|
11
|
+
if not value or not value.strip():
|
|
12
|
+
raise ValueError("FREESOLO_API_KEY is required to run the Freesolo Agent CLI")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_freesolo_api_key() -> None:
|
|
16
|
+
try:
|
|
17
|
+
FreesoloStorageClient().verify_api_key()
|
|
18
|
+
except httpx.HTTPStatusError as error:
|
|
19
|
+
if error.response.status_code in {401, 403}:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
"FREESOLO_API_KEY was rejected by Freesolo. Create a new API key "
|
|
22
|
+
"in the Freesolo app for the org that should own this run."
|
|
23
|
+
) from error
|
|
24
|
+
raise
|
client/datasets.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from freesolo.utils.storage import FreesoloStorageClient
|
|
7
|
+
|
|
8
|
+
from core.types import TrainingInputPayload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def dataset_path_from_input(bot_input: TrainingInputPayload) -> str | None:
|
|
12
|
+
return bot_input.dataset_path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def sync_dataset_if_present(bot_input: TrainingInputPayload) -> str | None:
|
|
16
|
+
dataset_path = dataset_path_from_input(bot_input)
|
|
17
|
+
if dataset_path is None:
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
return FreesoloStorageClient().upload_dataset(dataset_path, name=Path(dataset_path).name)
|
|
21
|
+
except httpx.HTTPStatusError as error:
|
|
22
|
+
if error.response.status_code in {401, 403}:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"Freesolo API key was rejected while uploading the dataset. Use an active "
|
|
25
|
+
"key for the org that should own datasets and training runs."
|
|
26
|
+
) from error
|
|
27
|
+
raise
|
client/jobs.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from freesolo.utils.storage import FreesoloStorageClient
|
|
11
|
+
|
|
12
|
+
from config import (
|
|
13
|
+
DEFAULT_JOB_POLL_INTERVAL_SECONDS,
|
|
14
|
+
JOB_HEARTBEAT_SECONDS,
|
|
15
|
+
TRAINING_JOBS_PATH,
|
|
16
|
+
)
|
|
17
|
+
from core.output import print_human
|
|
18
|
+
from core.types import (
|
|
19
|
+
DraftInput,
|
|
20
|
+
EditInput,
|
|
21
|
+
OptimizeInput,
|
|
22
|
+
TrainingInput,
|
|
23
|
+
TrainingInputPayload,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def job_poll_interval_seconds() -> float:
|
|
28
|
+
raw_value = os.getenv("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS")
|
|
29
|
+
if raw_value is None or not raw_value.strip():
|
|
30
|
+
return DEFAULT_JOB_POLL_INTERVAL_SECONDS
|
|
31
|
+
try:
|
|
32
|
+
value = float(raw_value)
|
|
33
|
+
except ValueError as error:
|
|
34
|
+
raise ValueError("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS must be a number") from error
|
|
35
|
+
if value <= 0:
|
|
36
|
+
raise ValueError("FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS must be positive")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def environment_variables_from_input(bot_input: TrainingInputPayload) -> dict[str, str]:
|
|
41
|
+
return dict(bot_input.environment_variables)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def backend_training_payload(
|
|
45
|
+
operation: str,
|
|
46
|
+
org_id: str,
|
|
47
|
+
bot_input: TrainingInputPayload,
|
|
48
|
+
dataset_id: str | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
payload: dict[str, Any] = {"action": operation, "orgId": org_id}
|
|
51
|
+
if dataset_id is not None:
|
|
52
|
+
payload["datasetId"] = dataset_id
|
|
53
|
+
|
|
54
|
+
environment_variables = environment_variables_from_input(bot_input)
|
|
55
|
+
if environment_variables:
|
|
56
|
+
payload["environmentVariables"] = environment_variables
|
|
57
|
+
|
|
58
|
+
if operation == "draft":
|
|
59
|
+
draft_input = cast(DraftInput, bot_input)
|
|
60
|
+
payload.update(
|
|
61
|
+
{
|
|
62
|
+
"sourceRepoUrl": draft_input.source_repo_url,
|
|
63
|
+
"sourceBranch": draft_input.source_branch,
|
|
64
|
+
"prompt": draft_input.prompt,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return payload
|
|
68
|
+
if operation == "edit":
|
|
69
|
+
edit_input = cast(EditInput, bot_input)
|
|
70
|
+
payload.update(
|
|
71
|
+
{
|
|
72
|
+
"targetRepoUrl": edit_input.target_repo_url,
|
|
73
|
+
"targetBranch": edit_input.target_branch,
|
|
74
|
+
"targetWorktreePath": edit_input.target_worktree_path,
|
|
75
|
+
"prompt": edit_input.prompt,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return payload
|
|
79
|
+
if operation == "optimize":
|
|
80
|
+
optimize_input = cast(OptimizeInput, bot_input)
|
|
81
|
+
payload.update(
|
|
82
|
+
{
|
|
83
|
+
"targetRepoUrl": optimize_input.target_repo_url,
|
|
84
|
+
"targetBranch": optimize_input.target_branch,
|
|
85
|
+
"targetWorktreePath": optimize_input.target_worktree_path,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
return payload
|
|
89
|
+
|
|
90
|
+
training_input = cast(TrainingInput, bot_input)
|
|
91
|
+
payload.update(
|
|
92
|
+
{
|
|
93
|
+
"targetRepoUrl": training_input.target_repo_url,
|
|
94
|
+
"targetBranch": training_input.target_branch,
|
|
95
|
+
"targetWorktreePath": training_input.target_worktree_path,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
return payload
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def backend_headers(
|
|
102
|
+
storage_client: FreesoloStorageClient,
|
|
103
|
+
*,
|
|
104
|
+
track_usage: bool = False,
|
|
105
|
+
) -> dict[str, str]:
|
|
106
|
+
headers = {
|
|
107
|
+
"Authorization": f"Bearer {storage_client.api_key}",
|
|
108
|
+
"Content-Type": "application/json",
|
|
109
|
+
}
|
|
110
|
+
if track_usage:
|
|
111
|
+
headers["X-Freesolo-Function"] = "freesolo_agent.run_backend_training_job"
|
|
112
|
+
return headers
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def backend_json_response(response: httpx.Response, path: str) -> dict[str, Any]:
|
|
116
|
+
try:
|
|
117
|
+
payload = response.json() if response.content else {}
|
|
118
|
+
except ValueError:
|
|
119
|
+
payload = {}
|
|
120
|
+
if response.is_error:
|
|
121
|
+
raise RuntimeError(backend_error_message(payload, f"Freesolo {path} failed"))
|
|
122
|
+
if not isinstance(payload, dict):
|
|
123
|
+
raise RuntimeError(f"Freesolo {path} returned a non-object response")
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def backend_error_message(payload: object, fallback: str) -> str:
|
|
128
|
+
if not isinstance(payload, dict):
|
|
129
|
+
return fallback
|
|
130
|
+
error = payload.get("error")
|
|
131
|
+
if isinstance(error, str) and error.strip():
|
|
132
|
+
return error
|
|
133
|
+
detail = payload.get("detail")
|
|
134
|
+
if isinstance(detail, str) and detail.strip():
|
|
135
|
+
return detail
|
|
136
|
+
return fallback
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def job_from_response(payload: dict[str, Any]) -> dict[str, Any]:
|
|
140
|
+
job = payload.get("job")
|
|
141
|
+
if not isinstance(job, dict):
|
|
142
|
+
raise RuntimeError("Freesolo training job response did not include a job object")
|
|
143
|
+
return dict(job)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def job_id(job: Mapping[str, Any]) -> str:
|
|
147
|
+
value = job.get("jobId")
|
|
148
|
+
if not isinstance(value, str) or not value:
|
|
149
|
+
raise RuntimeError("Freesolo training job response missing jobId")
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def job_status(job: Mapping[str, Any]) -> str:
|
|
154
|
+
value = job.get("status")
|
|
155
|
+
if not isinstance(value, str) or not value:
|
|
156
|
+
raise RuntimeError("Freesolo training job response missing status")
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def job_result(job: Mapping[str, Any]) -> dict[str, Any]:
|
|
161
|
+
result = job.get("result")
|
|
162
|
+
if not isinstance(result, dict):
|
|
163
|
+
raise RuntimeError("Freesolo training job succeeded without a result object")
|
|
164
|
+
return dict(result)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def run_backend_training_job(
|
|
168
|
+
operation: str,
|
|
169
|
+
org_id: str,
|
|
170
|
+
bot_input: TrainingInputPayload,
|
|
171
|
+
dataset_id: str | None = None,
|
|
172
|
+
) -> tuple[dict[str, Any], str]:
|
|
173
|
+
storage_client = FreesoloStorageClient()
|
|
174
|
+
enqueue_headers = backend_headers(storage_client, track_usage=True)
|
|
175
|
+
poll_headers = backend_headers(storage_client)
|
|
176
|
+
poll_interval_seconds = job_poll_interval_seconds()
|
|
177
|
+
payload = backend_training_payload(operation, org_id, bot_input, dataset_id=dataset_id)
|
|
178
|
+
|
|
179
|
+
async with httpx.AsyncClient(timeout=storage_client.timeout_seconds) as client:
|
|
180
|
+
enqueue_response = await client.post(
|
|
181
|
+
f"{storage_client.base_url}{TRAINING_JOBS_PATH}",
|
|
182
|
+
headers=enqueue_headers,
|
|
183
|
+
json=payload,
|
|
184
|
+
)
|
|
185
|
+
job = job_from_response(backend_json_response(enqueue_response, TRAINING_JOBS_PATH))
|
|
186
|
+
current_job_id = job_id(job)
|
|
187
|
+
print_human(f"Queued backend {operation} job {current_job_id}.")
|
|
188
|
+
return await poll_backend_training_job(
|
|
189
|
+
client=client,
|
|
190
|
+
base_url=storage_client.base_url,
|
|
191
|
+
headers=poll_headers,
|
|
192
|
+
job_id=current_job_id,
|
|
193
|
+
initial_job=job,
|
|
194
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def poll_backend_training_job(
|
|
199
|
+
*,
|
|
200
|
+
client: httpx.AsyncClient,
|
|
201
|
+
base_url: str,
|
|
202
|
+
headers: dict[str, str],
|
|
203
|
+
job_id: str,
|
|
204
|
+
initial_job: dict[str, Any],
|
|
205
|
+
poll_interval_seconds: float,
|
|
206
|
+
) -> tuple[dict[str, Any], str]:
|
|
207
|
+
job = initial_job
|
|
208
|
+
last_status: str | None = None
|
|
209
|
+
last_heartbeat_at = 0.0
|
|
210
|
+
path = f"{TRAINING_JOBS_PATH}/{job_id}"
|
|
211
|
+
while True:
|
|
212
|
+
status = job_status(job)
|
|
213
|
+
now = time.monotonic()
|
|
214
|
+
if status != last_status:
|
|
215
|
+
print_human(f"Backend job {job_id} is {status}.")
|
|
216
|
+
last_status = status
|
|
217
|
+
last_heartbeat_at = now
|
|
218
|
+
elif now - last_heartbeat_at >= JOB_HEARTBEAT_SECONDS:
|
|
219
|
+
print_human(f"Backend job {job_id} is still {status}.")
|
|
220
|
+
last_heartbeat_at = now
|
|
221
|
+
|
|
222
|
+
if status == "succeeded":
|
|
223
|
+
return job_result(job), job_id
|
|
224
|
+
if status in {"failed", "cancelled"}:
|
|
225
|
+
error = job.get("error")
|
|
226
|
+
if not isinstance(error, str) or not error.strip():
|
|
227
|
+
error = f"Backend training job {job_id} {status}"
|
|
228
|
+
raise RuntimeError(error)
|
|
229
|
+
|
|
230
|
+
await asyncio.sleep(poll_interval_seconds)
|
|
231
|
+
poll_response = await client.get(f"{base_url}{path}", headers=headers)
|
|
232
|
+
job = job_from_response(backend_json_response(poll_response, path))
|
commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Console command entrypoints."""
|
commands/main.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from client.auth import require_freesolo_api_key, verify_freesolo_api_key
|
|
10
|
+
from client.jobs import run_backend_training_job
|
|
11
|
+
from config import HELP_COMMANDS, INTERACTIVE_COMMANDS
|
|
12
|
+
from client.datasets import sync_dataset_if_present
|
|
13
|
+
from commands.setup import run_interactive
|
|
14
|
+
from core.output import print_human, write_json
|
|
15
|
+
from core.types import JsonDict
|
|
16
|
+
from core.validation import validate_request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_dotenv_if_available() -> None:
|
|
20
|
+
try:
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
except ImportError:
|
|
23
|
+
return
|
|
24
|
+
load_dotenv()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read_input() -> str:
|
|
28
|
+
if len(sys.argv) > 1:
|
|
29
|
+
return sys.argv[1]
|
|
30
|
+
return sys.stdin.read()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def usage() -> str:
|
|
34
|
+
return "\n".join(
|
|
35
|
+
[
|
|
36
|
+
"Usage:",
|
|
37
|
+
" freesolo",
|
|
38
|
+
" freesolo setup",
|
|
39
|
+
" freesolo '<json request>'",
|
|
40
|
+
"",
|
|
41
|
+
"No-argument TTY usage starts interactive setup. JSON usage enqueues backend",
|
|
42
|
+
"draft, edit, optimize, and training jobs, then polls until completion.",
|
|
43
|
+
"Pass top-level orgId for backend jobs.",
|
|
44
|
+
"Pass input.datasetPath as a local dataset file or folder path when you want",
|
|
45
|
+
"the backend worker to use uploaded data as a read-only source.",
|
|
46
|
+
"Pass environmentVariables or input.environmentVariables as KEY/value pairs",
|
|
47
|
+
"to make ephemeral env vars available only during the backend worker run.",
|
|
48
|
+
]
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def run() -> int:
|
|
53
|
+
try:
|
|
54
|
+
load_dotenv_if_available()
|
|
55
|
+
|
|
56
|
+
if len(sys.argv) > 1 and sys.argv[1] in HELP_COMMANDS:
|
|
57
|
+
print(usage())
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
if (len(sys.argv) > 1 and sys.argv[1] in INTERACTIVE_COMMANDS) or (
|
|
61
|
+
len(sys.argv) == 1 and sys.stdin.isatty()
|
|
62
|
+
):
|
|
63
|
+
return await run_interactive()
|
|
64
|
+
|
|
65
|
+
parsed_json = json.loads(read_input())
|
|
66
|
+
operation, bot_input, org_id = validate_request(parsed_json)
|
|
67
|
+
require_freesolo_api_key()
|
|
68
|
+
verify_freesolo_api_key()
|
|
69
|
+
dataset_id = sync_dataset_if_present(bot_input)
|
|
70
|
+
result_json, job_id = await run_backend_training_job(
|
|
71
|
+
operation,
|
|
72
|
+
org_id,
|
|
73
|
+
bot_input,
|
|
74
|
+
dataset_id=dataset_id,
|
|
75
|
+
)
|
|
76
|
+
if dataset_id:
|
|
77
|
+
result_json["datasetId"] = dataset_id
|
|
78
|
+
write_json(
|
|
79
|
+
sys.stdout,
|
|
80
|
+
cast(JsonDict, {"ok": True, "result": result_json, "jobId": job_id}),
|
|
81
|
+
)
|
|
82
|
+
return 0
|
|
83
|
+
except ValueError as error:
|
|
84
|
+
print_human(str(error))
|
|
85
|
+
write_json(sys.stderr, {"ok": False, "error": str(error)})
|
|
86
|
+
return 1
|
|
87
|
+
except Exception as error:
|
|
88
|
+
traceback.print_exc(file=sys.stderr)
|
|
89
|
+
write_json(sys.stderr, {"ok": False, "error": str(error)})
|
|
90
|
+
return 1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> None:
|
|
94
|
+
raise SystemExit(asyncio.run(run()))
|
commands/setup.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import getpass
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from client.auth import verify_freesolo_api_key
|
|
10
|
+
from client.jobs import run_backend_training_job
|
|
11
|
+
from config import GITHUB_APP_INSTALL_URL, MAX_ENVIRONMENT_VARIABLES
|
|
12
|
+
from client.datasets import sync_dataset_if_present
|
|
13
|
+
from core.output import print_human, write_json
|
|
14
|
+
from core.types import DraftInput, JsonDict
|
|
15
|
+
from core.validation import (
|
|
16
|
+
required_string,
|
|
17
|
+
validate_dataset_path,
|
|
18
|
+
validate_environment_variables,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def prompt_required_line(label: str, *, default: str | None = None) -> str:
|
|
23
|
+
default_suffix = f" [{default}]" if default else ""
|
|
24
|
+
while True:
|
|
25
|
+
print(f"{label}{default_suffix}: ", file=sys.stderr, end="", flush=True)
|
|
26
|
+
value = sys.stdin.readline()
|
|
27
|
+
if value == "":
|
|
28
|
+
raise EOFError(f"Missing input for {label}")
|
|
29
|
+
trimmed = value.strip()
|
|
30
|
+
if trimmed:
|
|
31
|
+
return trimmed
|
|
32
|
+
if default:
|
|
33
|
+
return default
|
|
34
|
+
print_human(f"{label} is required.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def prompt_optional_line(label: str) -> str | None:
|
|
38
|
+
print(f"{label} (optional, press Enter to skip): ", file=sys.stderr, end="", flush=True)
|
|
39
|
+
value = sys.stdin.readline()
|
|
40
|
+
if value == "":
|
|
41
|
+
return None
|
|
42
|
+
trimmed = value.strip()
|
|
43
|
+
return trimmed or None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def prompt_optional_dataset_path() -> str | None:
|
|
47
|
+
while True:
|
|
48
|
+
try:
|
|
49
|
+
return validate_dataset_path(prompt_optional_line("Dataset file or folder path"))
|
|
50
|
+
except ValueError as error:
|
|
51
|
+
print_human(str(error))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def prompt_environment_variables() -> dict[str, str]:
|
|
55
|
+
variables: dict[str, str] = {}
|
|
56
|
+
print_human("Environment variables for the backend training worker are optional.")
|
|
57
|
+
print_human("Enter variable names one at a time. Press Enter on the name prompt to finish.")
|
|
58
|
+
while True:
|
|
59
|
+
raw_name = prompt_optional_line("Environment variable name")
|
|
60
|
+
if raw_name is None:
|
|
61
|
+
return variables
|
|
62
|
+
name = raw_name.strip()
|
|
63
|
+
try:
|
|
64
|
+
validate_environment_variables({name: ""}, "environmentVariables")
|
|
65
|
+
except ValueError as error:
|
|
66
|
+
print_human(str(error))
|
|
67
|
+
continue
|
|
68
|
+
variables[name] = getpass.getpass(f"Value for {name}: ", stream=sys.stderr)
|
|
69
|
+
if len(variables) >= MAX_ENVIRONMENT_VARIABLES:
|
|
70
|
+
print_human(f"Reached the limit of {MAX_ENVIRONMENT_VARIABLES} variables.")
|
|
71
|
+
return variables
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def prompt_secret(label: str) -> str:
|
|
75
|
+
while True:
|
|
76
|
+
try:
|
|
77
|
+
value = getpass.getpass(f"{label}: ", stream=sys.stderr).strip()
|
|
78
|
+
except EOFError as error:
|
|
79
|
+
raise EOFError(f"Missing input for {label}") from error
|
|
80
|
+
if value:
|
|
81
|
+
return value
|
|
82
|
+
print_human(f"{label} is required.")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ensure_freesolo_api_key() -> None:
|
|
86
|
+
if os.getenv("FREESOLO_API_KEY"):
|
|
87
|
+
try:
|
|
88
|
+
verify_freesolo_api_key()
|
|
89
|
+
print_human("Using FREESOLO_API_KEY from environment.")
|
|
90
|
+
return
|
|
91
|
+
except ValueError as error:
|
|
92
|
+
print_human(str(error))
|
|
93
|
+
print_human("Enter a different Freesolo API key.")
|
|
94
|
+
|
|
95
|
+
while True:
|
|
96
|
+
os.environ["FREESOLO_API_KEY"] = prompt_secret("Freesolo API key")
|
|
97
|
+
try:
|
|
98
|
+
verify_freesolo_api_key()
|
|
99
|
+
print_human("Freesolo API key accepted.")
|
|
100
|
+
return
|
|
101
|
+
except ValueError as error:
|
|
102
|
+
print_human(str(error))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def prompt_multiline_description() -> str:
|
|
106
|
+
while True:
|
|
107
|
+
print_human("Describe what you are trying to do.")
|
|
108
|
+
print_human("Finish with a blank line.")
|
|
109
|
+
lines: list[str] = []
|
|
110
|
+
while True:
|
|
111
|
+
line = sys.stdin.readline()
|
|
112
|
+
if line == "":
|
|
113
|
+
if not lines:
|
|
114
|
+
raise EOFError("Missing prompt")
|
|
115
|
+
break
|
|
116
|
+
stripped = line.rstrip("\n")
|
|
117
|
+
if not stripped:
|
|
118
|
+
if lines:
|
|
119
|
+
break
|
|
120
|
+
print_human("Prompt is required.")
|
|
121
|
+
continue
|
|
122
|
+
lines.append(stripped)
|
|
123
|
+
|
|
124
|
+
prompt = "\n".join(lines)
|
|
125
|
+
try:
|
|
126
|
+
return required_string(prompt, "prompt", 20, 8_000)
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
print_human(str(error))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def offer_github_app_install() -> None:
|
|
132
|
+
print_human("If the source repository is private, connect the Freesolo GitHub App:")
|
|
133
|
+
print_human(GITHUB_APP_INSTALL_URL)
|
|
134
|
+
opened = webbrowser.open(GITHUB_APP_INSTALL_URL, new=2)
|
|
135
|
+
if not opened:
|
|
136
|
+
print_human("Open the URL above in your browser.")
|
|
137
|
+
print(
|
|
138
|
+
"Press Enter once GitHub access is connected, or press Enter to continue for public repos: ",
|
|
139
|
+
file=sys.stderr,
|
|
140
|
+
end="",
|
|
141
|
+
flush=True,
|
|
142
|
+
)
|
|
143
|
+
sys.stdin.readline()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def run_interactive() -> int:
|
|
147
|
+
ensure_freesolo_api_key()
|
|
148
|
+
org_id = required_string(
|
|
149
|
+
prompt_required_line("Freesolo org ID"),
|
|
150
|
+
"orgId",
|
|
151
|
+
1,
|
|
152
|
+
100,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
source_repo_url = required_string(
|
|
156
|
+
prompt_required_line("Source repository URL"),
|
|
157
|
+
"sourceRepoUrl",
|
|
158
|
+
1,
|
|
159
|
+
300,
|
|
160
|
+
)
|
|
161
|
+
offer_github_app_install()
|
|
162
|
+
prompt = prompt_multiline_description()
|
|
163
|
+
source_branch = prompt_optional_line("Source branch")
|
|
164
|
+
dataset_path = prompt_optional_dataset_path()
|
|
165
|
+
environment_variables = prompt_environment_variables()
|
|
166
|
+
input_data = DraftInput(
|
|
167
|
+
source_repo_url=source_repo_url,
|
|
168
|
+
source_branch=source_branch,
|
|
169
|
+
prompt=prompt,
|
|
170
|
+
dataset_path=dataset_path,
|
|
171
|
+
environment_variables=environment_variables,
|
|
172
|
+
)
|
|
173
|
+
print_human("Setup inputs collected. Queuing backend draft job.")
|
|
174
|
+
dataset_id = sync_dataset_if_present(input_data)
|
|
175
|
+
result_json, job_id = await run_backend_training_job(
|
|
176
|
+
"draft",
|
|
177
|
+
org_id,
|
|
178
|
+
input_data,
|
|
179
|
+
dataset_id=dataset_id,
|
|
180
|
+
)
|
|
181
|
+
if dataset_id:
|
|
182
|
+
result_json["datasetId"] = dataset_id
|
|
183
|
+
target_repo_url = result_json.get("targetRepoUrl")
|
|
184
|
+
if isinstance(target_repo_url, str) and target_repo_url:
|
|
185
|
+
print_human(f"Drafted training contract in {target_repo_url}")
|
|
186
|
+
write_json(
|
|
187
|
+
sys.stdout,
|
|
188
|
+
cast(JsonDict, {"ok": True, "result": result_json, "jobId": job_id}),
|
|
189
|
+
)
|
|
190
|
+
return 0
|
config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
HELP_COMMANDS = {"help", "-h", "--help"}
|
|
6
|
+
INTERACTIVE_COMMANDS = {"setup"}
|
|
7
|
+
|
|
8
|
+
TRAINING_JOBS_PATH = "/api/training/jobs"
|
|
9
|
+
DEFAULT_JOB_POLL_INTERVAL_SECONDS = 5.0
|
|
10
|
+
JOB_HEARTBEAT_SECONDS = 30.0
|
|
11
|
+
|
|
12
|
+
GITHUB_APP_SLUG = "freesolo-agent"
|
|
13
|
+
GITHUB_APP_INSTALL_URL = f"https://github.com/apps/{GITHUB_APP_SLUG}/installations/new"
|
|
14
|
+
|
|
15
|
+
MAX_ENVIRONMENT_VARIABLES = 100
|
|
16
|
+
MAX_ENVIRONMENT_VALUE_LENGTH = 100_000
|
|
17
|
+
ENVIRONMENT_VARIABLE_NAME_PATTERN = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Shared public CLI helpers."""
|
core/output.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from typing import TextIO
|
|
6
|
+
|
|
7
|
+
from core.types import JsonDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_human(message: str = "") -> None:
|
|
11
|
+
print(message, file=sys.stderr, flush=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_json(stream: TextIO, payload: JsonDict) -> None:
|
|
15
|
+
stream.write(f"{json.dumps(payload, separators=(',', ':'))}\n")
|
core/types.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TypeAlias
|
|
5
|
+
|
|
6
|
+
JsonValue: TypeAlias = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"]
|
|
7
|
+
JsonDict: TypeAlias = dict[str, JsonValue]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class DraftInput:
|
|
12
|
+
source_repo_url: str
|
|
13
|
+
prompt: str
|
|
14
|
+
source_branch: str | None = None
|
|
15
|
+
dataset_path: str | None = None
|
|
16
|
+
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class EditInput:
|
|
21
|
+
target_repo_url: str
|
|
22
|
+
prompt: str
|
|
23
|
+
target_branch: str | None = None
|
|
24
|
+
target_worktree_path: str | None = None
|
|
25
|
+
dataset_path: str | None = None
|
|
26
|
+
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class OptimizeInput:
|
|
31
|
+
target_repo_url: str
|
|
32
|
+
target_branch: str | None = None
|
|
33
|
+
target_worktree_path: str | None = None
|
|
34
|
+
dataset_path: str | None = None
|
|
35
|
+
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TrainingInput:
|
|
40
|
+
target_repo_url: str
|
|
41
|
+
target_branch: str | None = None
|
|
42
|
+
target_worktree_path: str | None = None
|
|
43
|
+
dataset_path: str | None = None
|
|
44
|
+
environment_variables: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
TrainingInputPayload: TypeAlias = DraftInput | EditInput | OptimizeInput | TrainingInput
|
core/validation.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from config import (
|
|
7
|
+
ENVIRONMENT_VARIABLE_NAME_PATTERN,
|
|
8
|
+
MAX_ENVIRONMENT_VALUE_LENGTH,
|
|
9
|
+
MAX_ENVIRONMENT_VARIABLES,
|
|
10
|
+
)
|
|
11
|
+
from core.types import DraftInput, EditInput, OptimizeInput, TrainingInput
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def as_mapping(value: object) -> Mapping[str, object]:
|
|
15
|
+
if not isinstance(value, dict):
|
|
16
|
+
raise ValueError("Input must be a JSON object")
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def optional_string(value: object, name: str, max_length: int) -> str | None:
|
|
21
|
+
if value is None or value == "":
|
|
22
|
+
return None
|
|
23
|
+
if not isinstance(value, str):
|
|
24
|
+
raise ValueError(f"{name} must be a string")
|
|
25
|
+
trimmed = value.strip()
|
|
26
|
+
if not trimmed:
|
|
27
|
+
return None
|
|
28
|
+
if len(trimmed) > max_length:
|
|
29
|
+
raise ValueError(f"{name} must be at most {max_length} characters")
|
|
30
|
+
return trimmed
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def required_string(value: object, name: str, min_length: int, max_length: int) -> str:
|
|
34
|
+
if not isinstance(value, str):
|
|
35
|
+
raise ValueError(f"{name} must be a string")
|
|
36
|
+
trimmed = value.strip()
|
|
37
|
+
if len(trimmed) < min_length:
|
|
38
|
+
raise ValueError(f"{name} must be at least {min_length} characters")
|
|
39
|
+
if len(trimmed) > max_length:
|
|
40
|
+
raise ValueError(f"{name} must be at most {max_length} characters")
|
|
41
|
+
return trimmed
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_dataset_path(value: object) -> str | None:
|
|
45
|
+
raw_path = optional_string(value, "datasetPath", 2_000)
|
|
46
|
+
if raw_path is None:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
resolved = Path(raw_path).expanduser().resolve(strict=True)
|
|
51
|
+
except OSError as error:
|
|
52
|
+
raise ValueError(f"datasetPath must exist: {raw_path}") from error
|
|
53
|
+
if not resolved.is_file() and not resolved.is_dir():
|
|
54
|
+
raise ValueError("datasetPath must be a file or folder")
|
|
55
|
+
return str(resolved)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_environment_variables(value: object, name: str) -> dict[str, str]:
|
|
59
|
+
if value is None:
|
|
60
|
+
return {}
|
|
61
|
+
if not isinstance(value, dict):
|
|
62
|
+
raise ValueError(f"{name} must be an object of environment variable names to values")
|
|
63
|
+
if len(value) > MAX_ENVIRONMENT_VARIABLES:
|
|
64
|
+
raise ValueError(f"{name} must include at most {MAX_ENVIRONMENT_VARIABLES} variables")
|
|
65
|
+
|
|
66
|
+
variables: dict[str, str] = {}
|
|
67
|
+
for raw_key, raw_value in value.items():
|
|
68
|
+
if not isinstance(raw_key, str):
|
|
69
|
+
raise ValueError(f"{name} keys must be strings")
|
|
70
|
+
key = raw_key.strip()
|
|
71
|
+
if not ENVIRONMENT_VARIABLE_NAME_PATTERN.fullmatch(key):
|
|
72
|
+
raise ValueError(f"{name} key {raw_key!r} must be a valid environment variable name")
|
|
73
|
+
if not isinstance(raw_value, str):
|
|
74
|
+
raise ValueError(f"{name}.{key} must be a string")
|
|
75
|
+
if len(raw_value) > MAX_ENVIRONMENT_VALUE_LENGTH:
|
|
76
|
+
raise ValueError(f"{name}.{key} is too large")
|
|
77
|
+
variables[key] = raw_value
|
|
78
|
+
return variables
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def merged_environment_variables(
|
|
82
|
+
request_environment_variables: dict[str, str],
|
|
83
|
+
input_payload: Mapping[str, object],
|
|
84
|
+
) -> dict[str, str]:
|
|
85
|
+
variables = dict(request_environment_variables)
|
|
86
|
+
variables.update(
|
|
87
|
+
validate_environment_variables(
|
|
88
|
+
input_payload.get("environmentVariables"),
|
|
89
|
+
"input.environmentVariables",
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
return variables
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validate_draft_input(
|
|
96
|
+
value: object,
|
|
97
|
+
request_environment_variables: dict[str, str] | None = None,
|
|
98
|
+
) -> DraftInput:
|
|
99
|
+
payload = as_mapping(value)
|
|
100
|
+
return DraftInput(
|
|
101
|
+
source_repo_url=required_string(payload.get("sourceRepoUrl"), "sourceRepoUrl", 1, 300),
|
|
102
|
+
source_branch=optional_string(payload.get("sourceBranch"), "sourceBranch", 120),
|
|
103
|
+
prompt=required_string(payload.get("prompt"), "prompt", 20, 8_000),
|
|
104
|
+
dataset_path=validate_dataset_path(payload.get("datasetPath")),
|
|
105
|
+
environment_variables=merged_environment_variables(
|
|
106
|
+
request_environment_variables or {},
|
|
107
|
+
payload,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_edit_input(
|
|
113
|
+
value: object,
|
|
114
|
+
request_environment_variables: dict[str, str] | None = None,
|
|
115
|
+
) -> EditInput:
|
|
116
|
+
payload = as_mapping(value)
|
|
117
|
+
return EditInput(
|
|
118
|
+
target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
|
|
119
|
+
target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
|
|
120
|
+
target_worktree_path=optional_string(
|
|
121
|
+
payload.get("targetWorktreePath"),
|
|
122
|
+
"targetWorktreePath",
|
|
123
|
+
2_000,
|
|
124
|
+
),
|
|
125
|
+
prompt=required_string(payload.get("prompt"), "prompt", 20, 8_000),
|
|
126
|
+
dataset_path=validate_dataset_path(payload.get("datasetPath")),
|
|
127
|
+
environment_variables=merged_environment_variables(
|
|
128
|
+
request_environment_variables or {},
|
|
129
|
+
payload,
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def validate_optimize_input(
|
|
135
|
+
value: object,
|
|
136
|
+
request_environment_variables: dict[str, str] | None = None,
|
|
137
|
+
) -> OptimizeInput:
|
|
138
|
+
payload = as_mapping(value)
|
|
139
|
+
return OptimizeInput(
|
|
140
|
+
target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
|
|
141
|
+
target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
|
|
142
|
+
target_worktree_path=optional_string(
|
|
143
|
+
payload.get("targetWorktreePath"),
|
|
144
|
+
"targetWorktreePath",
|
|
145
|
+
2_000,
|
|
146
|
+
),
|
|
147
|
+
dataset_path=validate_dataset_path(payload.get("datasetPath")),
|
|
148
|
+
environment_variables=merged_environment_variables(
|
|
149
|
+
request_environment_variables or {},
|
|
150
|
+
payload,
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def validate_training_input(
|
|
156
|
+
value: object,
|
|
157
|
+
request_environment_variables: dict[str, str] | None = None,
|
|
158
|
+
) -> TrainingInput:
|
|
159
|
+
payload = as_mapping(value)
|
|
160
|
+
return TrainingInput(
|
|
161
|
+
target_repo_url=required_string(payload.get("targetRepoUrl"), "targetRepoUrl", 1, 300),
|
|
162
|
+
target_branch=optional_string(payload.get("targetBranch"), "targetBranch", 120),
|
|
163
|
+
target_worktree_path=optional_string(
|
|
164
|
+
payload.get("targetWorktreePath"),
|
|
165
|
+
"targetWorktreePath",
|
|
166
|
+
2_000,
|
|
167
|
+
),
|
|
168
|
+
dataset_path=validate_dataset_path(payload.get("datasetPath")),
|
|
169
|
+
environment_variables=merged_environment_variables(
|
|
170
|
+
request_environment_variables or {},
|
|
171
|
+
payload,
|
|
172
|
+
),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_request(
|
|
177
|
+
value: object,
|
|
178
|
+
) -> tuple[str, DraftInput | EditInput | OptimizeInput | TrainingInput, str]:
|
|
179
|
+
payload = as_mapping(value)
|
|
180
|
+
org_id = request_org_id(payload)
|
|
181
|
+
if org_id is None:
|
|
182
|
+
raise ValueError(
|
|
183
|
+
"orgId is required for backend training jobs."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
request_environment_variables = validate_environment_variables(
|
|
187
|
+
payload.get("environmentVariables"),
|
|
188
|
+
"environmentVariables",
|
|
189
|
+
)
|
|
190
|
+
operation = payload.get("operation")
|
|
191
|
+
if operation == "draft":
|
|
192
|
+
return (
|
|
193
|
+
operation,
|
|
194
|
+
validate_draft_input(payload.get("input"), request_environment_variables),
|
|
195
|
+
org_id,
|
|
196
|
+
)
|
|
197
|
+
if operation == "edit":
|
|
198
|
+
return (
|
|
199
|
+
operation,
|
|
200
|
+
validate_edit_input(payload.get("input"), request_environment_variables),
|
|
201
|
+
org_id,
|
|
202
|
+
)
|
|
203
|
+
if operation == "optimize":
|
|
204
|
+
return (
|
|
205
|
+
operation,
|
|
206
|
+
validate_optimize_input(payload.get("input"), request_environment_variables),
|
|
207
|
+
org_id,
|
|
208
|
+
)
|
|
209
|
+
if operation == "training":
|
|
210
|
+
return (
|
|
211
|
+
operation,
|
|
212
|
+
validate_training_input(payload.get("input"), request_environment_variables),
|
|
213
|
+
org_id,
|
|
214
|
+
)
|
|
215
|
+
raise ValueError("operation must be 'draft', 'edit', 'optimize', or 'training'")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def request_org_id(payload: Mapping[str, object]) -> str | None:
|
|
219
|
+
return optional_string(payload.get("orgId"), "orgId", 100)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: freesolo-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Thin CLI for queuing Freesolo backend training jobs.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: freesolo>=0.2.4
|
|
8
|
+
Requires-Dist: httpx>=0.27.0
|
|
9
|
+
|
|
10
|
+
# Freesolo Agent CLI
|
|
11
|
+
|
|
12
|
+
Thin command-line client for queuing Freesolo backend training jobs.
|
|
13
|
+
|
|
14
|
+
The public package does not contain the Codex/GitHub/training worker. It only:
|
|
15
|
+
|
|
16
|
+
- validates JSON or interactive setup input
|
|
17
|
+
- verifies `FREESOLO_API_KEY`
|
|
18
|
+
- uploads a local dataset file or folder when `datasetPath` is provided
|
|
19
|
+
- enqueues `/api/training/jobs`
|
|
20
|
+
- polls `/api/training/jobs/{job_id}` until the backend worker finishes
|
|
21
|
+
|
|
22
|
+
The private worker implementation lives under `backend/src/training_agent`.
|
|
23
|
+
|
|
24
|
+
## Run
|
|
25
|
+
|
|
26
|
+
Interactive setup starts when the CLI is launched from a terminal without JSON
|
|
27
|
+
input:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv run freesolo
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Automation can call the JSON interface:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv run freesolo '{"operation":"draft","orgId":"org_123","input":{"sourceRepoUrl":"https://github.com/acme/app.git","prompt":"Create a contract for the feature behavior..."}}'
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Supported operations are `draft`, `edit`, `optimize`, and `training`.
|
|
40
|
+
|
|
41
|
+
To provide a local dataset, pass `datasetPath` as either a file or folder path.
|
|
42
|
+
The CLI uploads it to the backend first, then the backend worker materializes it
|
|
43
|
+
as read-only context for the training agent:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv run freesolo '{"operation":"training","orgId":"org_123","input":{"targetRepoUrl":"https://github.com/acme/app-freesolo-training","datasetPath":"/mnt/data/my_dataset"}}'
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Environment
|
|
50
|
+
|
|
51
|
+
- `FREESOLO_API_KEY`: required.
|
|
52
|
+
- `FREESOLO_BASE_URL`: optional backend URL override. Defaults to production.
|
|
53
|
+
- `FREESOLO_TRAINING_JOB_POLL_INTERVAL_SECONDS`: optional polling interval.
|
|
54
|
+
|
|
55
|
+
Private GitHub app credentials, Codex settings, Tinker dependencies, and training
|
|
56
|
+
runtime configuration belong to the backend worker, not this CLI package.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
config.py,sha256=AC1LcByjblijhQV3TKCGBJ7P_BrpgPwHXcxQquKE1NU,503
|
|
2
|
+
client/__init__.py,sha256=9OncA1h_wPELpOaU7M0Gwouit1kGf3UTUR54M1BpxBY,50
|
|
3
|
+
client/auth.py,sha256=uodCnyu4xjUO2AzJQbc0OwNTixn2qaxSP7GrSh5ptVw,750
|
|
4
|
+
client/datasets.py,sha256=8urx7m84ZnYucPyqnFrEip0C9OTrX9mgMDqbjxOkRAY,912
|
|
5
|
+
client/jobs.py,sha256=k9di7r2FgPZlmOjYzoO7Ixlc_hwBMN1hRISAYaIobAg,7684
|
|
6
|
+
commands/__init__.py,sha256=8SFWQutHdTx19_nZltLAQB2h8PW8JdkaZbAqdFbjymI,35
|
|
7
|
+
commands/main.py,sha256=UXogdRjxCs4PD9nzKqVSGl_L7W7LSERBdpmLC47zTg4,2935
|
|
8
|
+
commands/setup.py,sha256=HWjwck_1MBzXHcWmraqDETnuQu7Ii5lsZbZL-ayC8_4,6239
|
|
9
|
+
core/__init__.py,sha256=k5Uu8Rz33xqXZLrv-vxVXyVpWx2EntIxHBOdRqk37YE,33
|
|
10
|
+
core/output.py,sha256=Bsju7FWspjZK78JaUD2aB6kRN0lVTTLiDiXR3sJs_8A,341
|
|
11
|
+
core/types.py,sha256=kFNOeuMfHPdeBvphUvdVK-D6e0ebnJs0LTtpwSCkCzA,1366
|
|
12
|
+
core/validation.py,sha256=-i2qRK6AIQLWPBf5JOlDJWM53q2OfF8krCvL8E_cDRU,7732
|
|
13
|
+
freesolo_agent-0.1.0.dist-info/METADATA,sha256=gk_z3Mn1ip96Y0u_MuxwyuSNGDRJQFm1WAJyd5QlXLU,1920
|
|
14
|
+
freesolo_agent-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
15
|
+
freesolo_agent-0.1.0.dist-info/entry_points.txt,sha256=MPrdTTJ_JSaksgFOSkWjzdIcsLxSEfnWoj_1rQD-Ea0,48
|
|
16
|
+
freesolo_agent-0.1.0.dist-info/top_level.txt,sha256=QyqIt5ojO48_zy9d65hFfrNQZubtRvm3TyRFJvlBsY0,28
|
|
17
|
+
freesolo_agent-0.1.0.dist-info/RECORD,,
|