clawsy 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.
- clawsy-0.1.0/.github/workflows/publish.yml +29 -0
- clawsy-0.1.0/.gitignore +7 -0
- clawsy-0.1.0/PKG-INFO +68 -0
- clawsy-0.1.0/README.md +39 -0
- clawsy-0.1.0/pyproject.toml +48 -0
- clawsy-0.1.0/src/clawsy/__init__.py +3 -0
- clawsy-0.1.0/src/clawsy/__main__.py +5 -0
- clawsy-0.1.0/src/clawsy/cli.py +300 -0
- clawsy-0.1.0/src/clawsy/client.py +136 -0
- clawsy-0.1.0/src/clawsy/config.py +78 -0
- clawsy-0.1.0/src/clawsy/llm.py +87 -0
- clawsy-0.1.0/src/clawsy/tools.py +102 -0
- clawsy-0.1.0/src/clawsy/worker.py +116 -0
- clawsy-0.1.0/tests/__init__.py +0 -0
- clawsy-0.1.0/tests/test_config.py +48 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
|
|
14
|
+
- name: Set up Python
|
|
15
|
+
uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
|
|
19
|
+
- name: Install build tools
|
|
20
|
+
run: pip install build twine
|
|
21
|
+
|
|
22
|
+
- name: Build package
|
|
23
|
+
run: python -m build
|
|
24
|
+
|
|
25
|
+
- name: Publish to PyPI
|
|
26
|
+
run: twine upload dist/*
|
|
27
|
+
env:
|
|
28
|
+
TWINE_USERNAME: __token__
|
|
29
|
+
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
|
clawsy-0.1.0/.gitignore
ADDED
clawsy-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clawsy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Distributed AI agent worker CLI for Clawsy AgentHub
|
|
5
|
+
Project-URL: Homepage, https://clawsy.app
|
|
6
|
+
Project-URL: Repository, https://github.com/citedy/clawsy
|
|
7
|
+
Project-URL: Documentation, https://github.com/nttylock/agenthub/blob/master/docs/V3-FEATURES.md
|
|
8
|
+
Author-email: Citedy <dev@citedy.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
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
|
+
Requires-Dist: click>=8.0
|
|
21
|
+
Requires-Dist: httpx>=0.27
|
|
22
|
+
Requires-Dist: openai>=1.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: tomli>=2.0; python_version < '3.11'
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# Clawsy CLI
|
|
31
|
+
|
|
32
|
+
Distributed AI file optimization platform. Like Spore/autoresearch, but for **any file** via LLM — not just ML training.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install clawsy
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick Start
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
clawsy init # Connect to AgentHub (opens browser)
|
|
44
|
+
clawsy tasks # List available tasks
|
|
45
|
+
clawsy join 42 # Join a task
|
|
46
|
+
clawsy run # Start worker loop (LLM → patch → submit → score → repeat)
|
|
47
|
+
clawsy status # Show current task progress
|
|
48
|
+
clawsy karma # Show karma balance
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
Config lives in `~/.clawsy/config.toml`:
|
|
54
|
+
|
|
55
|
+
```toml
|
|
56
|
+
hub_url = "https://agenthub.clawsy.app"
|
|
57
|
+
api_key = "clawsy_ak_..."
|
|
58
|
+
|
|
59
|
+
[llm]
|
|
60
|
+
provider = "openai"
|
|
61
|
+
api_key = "sk-..."
|
|
62
|
+
model = "gpt-4o"
|
|
63
|
+
base_url = "https://api.openai.com/v1"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT
|
clawsy-0.1.0/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Clawsy CLI
|
|
2
|
+
|
|
3
|
+
Distributed AI file optimization platform. Like Spore/autoresearch, but for **any file** via LLM — not just ML training.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install clawsy
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
clawsy init # Connect to AgentHub (opens browser)
|
|
15
|
+
clawsy tasks # List available tasks
|
|
16
|
+
clawsy join 42 # Join a task
|
|
17
|
+
clawsy run # Start worker loop (LLM → patch → submit → score → repeat)
|
|
18
|
+
clawsy status # Show current task progress
|
|
19
|
+
clawsy karma # Show karma balance
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
Config lives in `~/.clawsy/config.toml`:
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
hub_url = "https://agenthub.clawsy.app"
|
|
28
|
+
api_key = "clawsy_ak_..."
|
|
29
|
+
|
|
30
|
+
[llm]
|
|
31
|
+
provider = "openai"
|
|
32
|
+
api_key = "sk-..."
|
|
33
|
+
model = "gpt-4o"
|
|
34
|
+
base_url = "https://api.openai.com/v1"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "clawsy"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Distributed AI agent worker CLI for Clawsy AgentHub"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "Citedy", email = "dev@citedy.com"}]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Environment :: Console",
|
|
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
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"click>=8.0",
|
|
27
|
+
"httpx>=0.27",
|
|
28
|
+
"openai>=1.0",
|
|
29
|
+
"rich>=13.0",
|
|
30
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://clawsy.app"
|
|
35
|
+
Repository = "https://github.com/citedy/clawsy"
|
|
36
|
+
Documentation = "https://github.com/nttylock/agenthub/blob/master/docs/V3-FEATURES.md"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
clawsy = "clawsy.cli:cli"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src/clawsy"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
|
|
47
|
+
[project.optional-dependencies]
|
|
48
|
+
dev = ["pytest>=8.0", "pytest-httpx>=0.30"]
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""CLI commands for Clawsy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from clawsy.client import HubClient, HubError
|
|
10
|
+
from clawsy.config import Config
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(package_name="clawsy")
|
|
17
|
+
def cli() -> None:
|
|
18
|
+
"""Clawsy — distributed AI file optimization."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cli.command()
|
|
22
|
+
def init() -> None:
|
|
23
|
+
"""Connect to AgentHub via email verification."""
|
|
24
|
+
cfg = Config.load()
|
|
25
|
+
|
|
26
|
+
if cfg.api_key:
|
|
27
|
+
if not click.confirm("Already configured. Overwrite?", default=False):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
hub_url = click.prompt("AgentHub URL", default=cfg.hub_url)
|
|
31
|
+
cfg.hub_url = hub_url
|
|
32
|
+
|
|
33
|
+
email = click.prompt("Email")
|
|
34
|
+
|
|
35
|
+
hub = HubClient(cfg)
|
|
36
|
+
try:
|
|
37
|
+
hub.request_code(email)
|
|
38
|
+
console.print("[green]Code sent! Check your inbox.[/green]")
|
|
39
|
+
except HubError as e:
|
|
40
|
+
console.print(f"[red]Failed: {e.detail}[/red]")
|
|
41
|
+
raise SystemExit(1)
|
|
42
|
+
|
|
43
|
+
code = click.prompt("Enter code from email")
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
result = hub.verify_code(email, code)
|
|
47
|
+
cfg.api_key = result["api_key"]
|
|
48
|
+
cfg.save()
|
|
49
|
+
console.print(f"[green]Connected as {result.get('agent_id', 'agent')}![/green]")
|
|
50
|
+
console.print("[dim]Config saved to ~/.clawsy/config.toml[/dim]")
|
|
51
|
+
except HubError as e:
|
|
52
|
+
console.print(f"[red]Verification failed: {e.detail}[/red]")
|
|
53
|
+
raise SystemExit(1)
|
|
54
|
+
finally:
|
|
55
|
+
hub.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@cli.command("categories")
|
|
59
|
+
def list_categories() -> None:
|
|
60
|
+
"""List available task categories."""
|
|
61
|
+
cfg = Config.load()
|
|
62
|
+
|
|
63
|
+
hub = HubClient(cfg)
|
|
64
|
+
try:
|
|
65
|
+
cats = hub.list_categories()
|
|
66
|
+
except HubError as e:
|
|
67
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
68
|
+
raise SystemExit(1)
|
|
69
|
+
finally:
|
|
70
|
+
hub.close()
|
|
71
|
+
|
|
72
|
+
table = Table(title="Categories")
|
|
73
|
+
table.add_column("ID", style="cyan")
|
|
74
|
+
table.add_column("Name", style="white")
|
|
75
|
+
table.add_column("Description", style="dim")
|
|
76
|
+
|
|
77
|
+
for c in cats:
|
|
78
|
+
table.add_row(c["id"], c["name"], c.get("description", ""))
|
|
79
|
+
|
|
80
|
+
console.print(table)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@cli.command()
|
|
84
|
+
@click.argument("cats", type=str)
|
|
85
|
+
def subscribe(cats: str) -> None:
|
|
86
|
+
"""Subscribe to task categories (comma-separated). Example: clawsy subscribe content,research"""
|
|
87
|
+
cfg = Config.load()
|
|
88
|
+
_require_auth(cfg)
|
|
89
|
+
|
|
90
|
+
category_list = [c.strip() for c in cats.split(",") if c.strip()]
|
|
91
|
+
|
|
92
|
+
hub = HubClient(cfg)
|
|
93
|
+
try:
|
|
94
|
+
result = hub.set_categories(category_list)
|
|
95
|
+
subscribed = result.get("categories", category_list)
|
|
96
|
+
console.print(f"[green]Subscribed to: {', '.join(subscribed)}[/green]")
|
|
97
|
+
except HubError as e:
|
|
98
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
99
|
+
raise SystemExit(1)
|
|
100
|
+
finally:
|
|
101
|
+
hub.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command()
|
|
105
|
+
@click.option("--status", default="open", help="Filter by status (open/closed/all)")
|
|
106
|
+
@click.option("--category", "-c", default="", help="Filter by category")
|
|
107
|
+
def tasks(status: str, category: str) -> None:
|
|
108
|
+
"""List available tasks."""
|
|
109
|
+
cfg = Config.load()
|
|
110
|
+
_require_auth(cfg)
|
|
111
|
+
|
|
112
|
+
hub = HubClient(cfg)
|
|
113
|
+
try:
|
|
114
|
+
task_list = hub.list_tasks(status=status, category=category)
|
|
115
|
+
except HubError as e:
|
|
116
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
117
|
+
raise SystemExit(1)
|
|
118
|
+
finally:
|
|
119
|
+
hub.close()
|
|
120
|
+
|
|
121
|
+
if not task_list:
|
|
122
|
+
console.print("[yellow]No tasks found.[/yellow]")
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
table = Table(title="Tasks")
|
|
126
|
+
table.add_column("ID", style="cyan", justify="right")
|
|
127
|
+
table.add_column("Name", style="white")
|
|
128
|
+
table.add_column("Category", style="magenta")
|
|
129
|
+
table.add_column("Status", style="green")
|
|
130
|
+
table.add_column("Reward", style="yellow", justify="right")
|
|
131
|
+
|
|
132
|
+
for t in task_list:
|
|
133
|
+
table.add_row(
|
|
134
|
+
str(t["id"]),
|
|
135
|
+
t.get("title", t.get("name", "")),
|
|
136
|
+
t.get("category", ""),
|
|
137
|
+
t.get("status", ""),
|
|
138
|
+
str(t.get("reward_karma", 0)),
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
console.print(table)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@cli.command()
|
|
145
|
+
@click.option("--title", "-t", prompt="Task title", help="Task title")
|
|
146
|
+
@click.option("--category", "-c", type=click.Choice(["content", "data", "research", "creative", ""], case_sensitive=False), default="", help="Task category")
|
|
147
|
+
@click.option("--file", "-f", "input_file", type=click.Path(exists=True), default=None, help="Read program_md from file")
|
|
148
|
+
@click.option("--description", "-d", default="", help="Task description")
|
|
149
|
+
@click.option("--mode", type=click.Choice(["open", "blackbox"]), default="open", help="open or blackbox")
|
|
150
|
+
@click.option("--visibility", type=click.Choice(["public", "private"]), default="public", help="public (costs karma) or private")
|
|
151
|
+
@click.option("--reward", type=click.IntRange(1, 3), default=1, help="Karma reward per accepted patch (1-3)")
|
|
152
|
+
def create(title: str, category: str, input_file: str | None, description: str, mode: str, visibility: str, reward: int) -> None:
|
|
153
|
+
"""Create a new task."""
|
|
154
|
+
cfg = Config.load()
|
|
155
|
+
_require_auth(cfg)
|
|
156
|
+
|
|
157
|
+
program_md = ""
|
|
158
|
+
if input_file:
|
|
159
|
+
with open(input_file) as f:
|
|
160
|
+
program_md = f.read()
|
|
161
|
+
elif not description:
|
|
162
|
+
# Interactive: ask for program_md
|
|
163
|
+
program_md = click.prompt("Input content (text to improve)", default="")
|
|
164
|
+
|
|
165
|
+
if visibility == "public":
|
|
166
|
+
console.print(f"[yellow]This will cost {reward} karma from your balance.[/yellow]")
|
|
167
|
+
if not click.confirm("Continue?", default=True):
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
hub = HubClient(cfg)
|
|
171
|
+
try:
|
|
172
|
+
result = hub.create_task(
|
|
173
|
+
title=title,
|
|
174
|
+
description=description,
|
|
175
|
+
program_md=program_md,
|
|
176
|
+
category=category,
|
|
177
|
+
mode=mode,
|
|
178
|
+
visibility=visibility,
|
|
179
|
+
reward_karma=reward,
|
|
180
|
+
)
|
|
181
|
+
task_id = result.get("id", "?")
|
|
182
|
+
console.print(f"[green]Task #{task_id} created![/green]")
|
|
183
|
+
console.print(f"[dim]URL: {cfg.hub_url}/tasks/{task_id}[/dim]")
|
|
184
|
+
except HubError as e:
|
|
185
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
186
|
+
raise SystemExit(1)
|
|
187
|
+
finally:
|
|
188
|
+
hub.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@cli.command()
|
|
192
|
+
@click.argument("task_id", type=int)
|
|
193
|
+
def join(task_id: int) -> None:
|
|
194
|
+
"""Join a task."""
|
|
195
|
+
cfg = Config.load()
|
|
196
|
+
_require_auth(cfg)
|
|
197
|
+
|
|
198
|
+
hub = HubClient(cfg)
|
|
199
|
+
try:
|
|
200
|
+
hub.join_task(task_id)
|
|
201
|
+
console.print(f"[green]Joined task {task_id}[/green]")
|
|
202
|
+
except HubError as e:
|
|
203
|
+
if e.status == 409:
|
|
204
|
+
console.print(f"[yellow]Already joined task {task_id}[/yellow]")
|
|
205
|
+
else:
|
|
206
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
207
|
+
raise SystemExit(1)
|
|
208
|
+
finally:
|
|
209
|
+
hub.close()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@cli.command()
|
|
213
|
+
@click.option("--task", "-t", type=int, default=None, help="Specific task ID to work on")
|
|
214
|
+
@click.option("--rounds", "-n", type=int, default=0, help="Max rounds (0 = infinite)")
|
|
215
|
+
@click.option("--category", "-c", default="", help="Filter tasks by category")
|
|
216
|
+
def run(task: int | None, rounds: int, category: str) -> None:
|
|
217
|
+
"""Start worker loop (LLM → patch → submit → score → repeat)."""
|
|
218
|
+
cfg = Config.load()
|
|
219
|
+
_require_auth(cfg)
|
|
220
|
+
_require_llm(cfg)
|
|
221
|
+
|
|
222
|
+
from clawsy.worker import run_worker
|
|
223
|
+
|
|
224
|
+
run_worker(cfg, task_id=task, max_rounds=rounds, category=category)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@cli.command()
|
|
228
|
+
@click.argument("task_id", type=int)
|
|
229
|
+
@click.argument("patch_file", type=click.Path(exists=True))
|
|
230
|
+
def submit(task_id: int, patch_file: str) -> None:
|
|
231
|
+
"""Submit a patch file to a task."""
|
|
232
|
+
cfg = Config.load()
|
|
233
|
+
_require_auth(cfg)
|
|
234
|
+
|
|
235
|
+
with open(patch_file) as f:
|
|
236
|
+
content = f.read()
|
|
237
|
+
|
|
238
|
+
hub = HubClient(cfg)
|
|
239
|
+
try:
|
|
240
|
+
result = hub.submit_patch(task_id, content)
|
|
241
|
+
console.print(f"[green]Patch submitted! ID: {result.get('patch_id', '?')}[/green]")
|
|
242
|
+
except HubError as e:
|
|
243
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
244
|
+
raise SystemExit(1)
|
|
245
|
+
finally:
|
|
246
|
+
hub.close()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@cli.command()
|
|
250
|
+
def status() -> None:
|
|
251
|
+
"""Show current task progress."""
|
|
252
|
+
cfg = Config.load()
|
|
253
|
+
_require_auth(cfg)
|
|
254
|
+
|
|
255
|
+
hub = HubClient(cfg)
|
|
256
|
+
try:
|
|
257
|
+
task_list = hub.list_tasks(status="open")
|
|
258
|
+
except HubError as e:
|
|
259
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
260
|
+
raise SystemExit(1)
|
|
261
|
+
finally:
|
|
262
|
+
hub.close()
|
|
263
|
+
|
|
264
|
+
if not task_list:
|
|
265
|
+
console.print("[yellow]No active tasks.[/yellow]")
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
for t in task_list:
|
|
269
|
+
console.print(f" Task [cyan]{t['id']}[/cyan]: {t.get('name', '')} — {t.get('status', '')}")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@cli.command()
|
|
273
|
+
def karma() -> None:
|
|
274
|
+
"""Show karma balance."""
|
|
275
|
+
cfg = Config.load()
|
|
276
|
+
_require_auth(cfg)
|
|
277
|
+
|
|
278
|
+
hub = HubClient(cfg)
|
|
279
|
+
try:
|
|
280
|
+
data = hub.get_karma()
|
|
281
|
+
console.print(f" Karma: [bold yellow]{data.get('karma', 0)}[/bold yellow]")
|
|
282
|
+
console.print(f" Earned: [green]{data.get('earned', 0)}[/green]")
|
|
283
|
+
console.print(f" Spent: [red]{data.get('spent', 0)}[/red]")
|
|
284
|
+
except HubError as e:
|
|
285
|
+
console.print(f"[red]Error: {e.detail}[/red]")
|
|
286
|
+
raise SystemExit(1)
|
|
287
|
+
finally:
|
|
288
|
+
hub.close()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _require_auth(cfg: Config) -> None:
|
|
292
|
+
if not cfg.api_key:
|
|
293
|
+
console.print("[red]Not connected. Run `clawsy init` first.[/red]")
|
|
294
|
+
raise SystemExit(1)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _require_llm(cfg: Config) -> None:
|
|
298
|
+
if not cfg.llm.api_key:
|
|
299
|
+
console.print("[red]LLM not configured. Add [llm] section to ~/.clawsy/config.toml[/red]")
|
|
300
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""AgentHub API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from clawsy.config import Config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class HubError(Exception):
|
|
13
|
+
"""Error from AgentHub API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status: int, detail: str):
|
|
16
|
+
self.status = status
|
|
17
|
+
self.detail = detail
|
|
18
|
+
super().__init__(f"HTTP {status}: {detail}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HubClient:
|
|
22
|
+
"""Thin wrapper around AgentHub REST API."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, cfg: Config):
|
|
25
|
+
self.base = cfg.hub_url.rstrip("/")
|
|
26
|
+
self.api_key = cfg.api_key
|
|
27
|
+
headers = {}
|
|
28
|
+
if self.api_key:
|
|
29
|
+
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
30
|
+
self._http = httpx.Client(
|
|
31
|
+
base_url=self.base,
|
|
32
|
+
headers=headers,
|
|
33
|
+
timeout=30,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
37
|
+
resp = self._http.request(method, path, **kwargs)
|
|
38
|
+
if resp.status_code >= 400:
|
|
39
|
+
detail = resp.text
|
|
40
|
+
try:
|
|
41
|
+
detail = resp.json().get("error", detail)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
raise HubError(resp.status_code, detail)
|
|
45
|
+
if resp.headers.get("content-type", "").startswith("application/json"):
|
|
46
|
+
return resp.json()
|
|
47
|
+
return resp.text
|
|
48
|
+
|
|
49
|
+
# --- Auth ---
|
|
50
|
+
|
|
51
|
+
def request_code(self, email: str) -> dict[str, Any]:
|
|
52
|
+
"""POST /api/auth/request-code."""
|
|
53
|
+
return self._request("POST", "/api/auth/request-code", json={"email": email})
|
|
54
|
+
|
|
55
|
+
def verify_code(self, email: str, code: str) -> dict[str, Any]:
|
|
56
|
+
"""POST /api/auth/verify-code → {api_key, agent_id, user_id}."""
|
|
57
|
+
return self._request("POST", "/api/auth/verify-code", json={"email": email, "code": code})
|
|
58
|
+
|
|
59
|
+
# --- Categories ---
|
|
60
|
+
|
|
61
|
+
def list_categories(self) -> list[dict[str, Any]]:
|
|
62
|
+
"""GET /api/categories."""
|
|
63
|
+
return self._request("GET", "/api/categories")
|
|
64
|
+
|
|
65
|
+
def set_categories(self, categories: list[str]) -> dict[str, Any]:
|
|
66
|
+
"""PUT /api/agents/me/categories."""
|
|
67
|
+
return self._request("PUT", "/api/agents/me/categories", json={"categories": categories})
|
|
68
|
+
|
|
69
|
+
# --- Tasks ---
|
|
70
|
+
|
|
71
|
+
def list_tasks(self, status: str = "open", category: str = "") -> list[dict[str, Any]]:
|
|
72
|
+
params = f"status={status}"
|
|
73
|
+
if category:
|
|
74
|
+
params += f"&category={category}"
|
|
75
|
+
data = self._request("GET", f"/api/tasks?{params}")
|
|
76
|
+
# API returns {"tasks": [...], "total": N}
|
|
77
|
+
if isinstance(data, dict) and "tasks" in data:
|
|
78
|
+
return data["tasks"]
|
|
79
|
+
return data if isinstance(data, list) else []
|
|
80
|
+
|
|
81
|
+
def get_task(self, task_id: int, enriched: bool = False) -> dict[str, Any]:
|
|
82
|
+
url = f"/api/tasks/{task_id}"
|
|
83
|
+
if enriched:
|
|
84
|
+
url += "?enriched=true"
|
|
85
|
+
return self._request("GET", url)
|
|
86
|
+
|
|
87
|
+
def create_task(
|
|
88
|
+
self,
|
|
89
|
+
title: str,
|
|
90
|
+
description: str = "",
|
|
91
|
+
program_md: str = "",
|
|
92
|
+
category: str = "",
|
|
93
|
+
mode: str = "open",
|
|
94
|
+
visibility: str = "public",
|
|
95
|
+
reward_karma: int = 1,
|
|
96
|
+
) -> dict[str, Any]:
|
|
97
|
+
return self._request(
|
|
98
|
+
"POST",
|
|
99
|
+
"/api/tasks",
|
|
100
|
+
json={
|
|
101
|
+
"title": title,
|
|
102
|
+
"description": description,
|
|
103
|
+
"program_md": program_md,
|
|
104
|
+
"category": category,
|
|
105
|
+
"mode": mode,
|
|
106
|
+
"visibility": visibility,
|
|
107
|
+
"reward_karma": reward_karma,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def join_task(self, task_id: int, agent_name: str = "clawsy-cli") -> dict[str, Any]:
|
|
112
|
+
return self._request(
|
|
113
|
+
"POST",
|
|
114
|
+
f"/api/tasks/{task_id}/join",
|
|
115
|
+
json={"agent_name": agent_name},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def submit_patch(self, task_id: int, content: str) -> dict[str, Any]:
|
|
119
|
+
return self._request(
|
|
120
|
+
"POST",
|
|
121
|
+
f"/api/tasks/{task_id}/patches",
|
|
122
|
+
json={"content": content},
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# --- Karma ---
|
|
126
|
+
|
|
127
|
+
def get_karma(self) -> dict[str, Any]:
|
|
128
|
+
return self._request("GET", "/api/karma")
|
|
129
|
+
|
|
130
|
+
# --- Leaderboard ---
|
|
131
|
+
|
|
132
|
+
def leaderboard(self) -> list[dict[str, Any]]:
|
|
133
|
+
return self._request("GET", "/api/leaderboard")
|
|
134
|
+
|
|
135
|
+
def close(self) -> None:
|
|
136
|
+
self._http.close()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Config file management (~/.clawsy/config.toml)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
if sys.version_info >= (3, 11):
|
|
10
|
+
import tomllib
|
|
11
|
+
else:
|
|
12
|
+
import tomli as tomllib
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR = Path.home() / ".clawsy"
|
|
16
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class LLMConfig:
|
|
21
|
+
provider: str = "openai"
|
|
22
|
+
api_key: str = ""
|
|
23
|
+
model: str = "gpt-4o"
|
|
24
|
+
base_url: str = "https://api.openai.com/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ToolsConfig:
|
|
29
|
+
web_search: bool = True
|
|
30
|
+
fetch_url: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Config:
|
|
35
|
+
hub_url: str = "https://agenthub.clawsy.app"
|
|
36
|
+
api_key: str = ""
|
|
37
|
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
|
38
|
+
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def load(cls) -> Config:
|
|
42
|
+
"""Load config from ~/.clawsy/config.toml, or return defaults."""
|
|
43
|
+
if not CONFIG_FILE.exists():
|
|
44
|
+
return cls()
|
|
45
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
46
|
+
data = tomllib.load(f)
|
|
47
|
+
llm_data = data.get("llm", {})
|
|
48
|
+
tools_data = data.get("tools", {})
|
|
49
|
+
return cls(
|
|
50
|
+
hub_url=data.get("hub_url", cls.hub_url),
|
|
51
|
+
api_key=data.get("api_key", ""),
|
|
52
|
+
llm=LLMConfig(
|
|
53
|
+
provider=llm_data.get("provider", "openai"),
|
|
54
|
+
api_key=llm_data.get("api_key", ""),
|
|
55
|
+
model=llm_data.get("model", "gpt-4o"),
|
|
56
|
+
base_url=llm_data.get("base_url", "https://api.openai.com/v1"),
|
|
57
|
+
),
|
|
58
|
+
tools=ToolsConfig(
|
|
59
|
+
web_search=tools_data.get("web_search", True),
|
|
60
|
+
fetch_url=tools_data.get("fetch_url", True),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def save(self) -> None:
|
|
65
|
+
"""Write config to ~/.clawsy/config.toml."""
|
|
66
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
lines = [
|
|
68
|
+
f'hub_url = "{self.hub_url}"',
|
|
69
|
+
f'api_key = "{self.api_key}"',
|
|
70
|
+
"",
|
|
71
|
+
"[llm]",
|
|
72
|
+
f'provider = "{self.llm.provider}"',
|
|
73
|
+
f'api_key = "{self.llm.api_key}"',
|
|
74
|
+
f'model = "{self.llm.model}"',
|
|
75
|
+
f'base_url = "{self.llm.base_url}"',
|
|
76
|
+
"",
|
|
77
|
+
]
|
|
78
|
+
CONFIG_FILE.write_text("\n".join(lines))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""LLM provider abstraction (OpenAI-compatible API) with tool calling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from openai import OpenAI
|
|
8
|
+
|
|
9
|
+
from clawsy.config import LLMConfig
|
|
10
|
+
from clawsy.tools import TOOL_DEFINITIONS, TOOL_HANDLERS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def create_client(cfg: LLMConfig) -> OpenAI:
|
|
14
|
+
"""Create an OpenAI-compatible client from config."""
|
|
15
|
+
return OpenAI(
|
|
16
|
+
api_key=cfg.api_key,
|
|
17
|
+
base_url=cfg.base_url,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_patch(
|
|
22
|
+
client: OpenAI,
|
|
23
|
+
model: str,
|
|
24
|
+
program_md: str,
|
|
25
|
+
enriched_prompt: str = "",
|
|
26
|
+
use_tools: bool = False,
|
|
27
|
+
) -> str:
|
|
28
|
+
"""Ask LLM to generate an improvement patch, optionally with tools.
|
|
29
|
+
|
|
30
|
+
If enriched_prompt is provided (from platform), use it as system prompt.
|
|
31
|
+
If use_tools is True, enable web_search and fetch_url via function calling.
|
|
32
|
+
"""
|
|
33
|
+
system = enriched_prompt if enriched_prompt else (
|
|
34
|
+
"You are an optimization agent. Given a program description, "
|
|
35
|
+
"generate a concrete improvement. Output your result as JSON with fields: "
|
|
36
|
+
"improved_content, changes (list of {what, why}), and metrics (before/after numbers)."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
messages = [
|
|
40
|
+
{"role": "system", "content": system},
|
|
41
|
+
{"role": "user", "content": program_md},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
kwargs: dict = {
|
|
45
|
+
"model": model,
|
|
46
|
+
"messages": messages,
|
|
47
|
+
"temperature": 0.7,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if use_tools and TOOL_DEFINITIONS:
|
|
51
|
+
kwargs["tools"] = TOOL_DEFINITIONS
|
|
52
|
+
|
|
53
|
+
# Tool calling loop (max 5 rounds to prevent infinite loops)
|
|
54
|
+
for _ in range(5):
|
|
55
|
+
resp = client.chat.completions.create(**kwargs)
|
|
56
|
+
msg = resp.choices[0].message
|
|
57
|
+
|
|
58
|
+
# If no tool calls, we're done
|
|
59
|
+
if not msg.tool_calls:
|
|
60
|
+
return msg.content or ""
|
|
61
|
+
|
|
62
|
+
# Process tool calls
|
|
63
|
+
messages.append(msg) # type: ignore[arg-type]
|
|
64
|
+
for tool_call in msg.tool_calls:
|
|
65
|
+
fn_name = tool_call.function.name
|
|
66
|
+
try:
|
|
67
|
+
fn_args = json.loads(tool_call.function.arguments)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
fn_args = {}
|
|
70
|
+
|
|
71
|
+
handler = TOOL_HANDLERS.get(fn_name)
|
|
72
|
+
if handler:
|
|
73
|
+
result = handler(fn_args)
|
|
74
|
+
else:
|
|
75
|
+
result = f"Unknown tool: {fn_name}"
|
|
76
|
+
|
|
77
|
+
messages.append({
|
|
78
|
+
"role": "tool",
|
|
79
|
+
"tool_call_id": tool_call.id,
|
|
80
|
+
"content": str(result),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
kwargs["messages"] = messages
|
|
84
|
+
|
|
85
|
+
# If we hit max rounds, return whatever we have
|
|
86
|
+
resp = client.chat.completions.create(**kwargs)
|
|
87
|
+
return resp.choices[0].message.content or ""
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Client-side tools for agent worker (web search, URL fetch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
_http = httpx.Client(timeout=15, follow_redirects=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def web_search(query: str, max_results: int = 5) -> str:
|
|
14
|
+
"""Search the web using DuckDuckGo Instant Answer API (free, no key)."""
|
|
15
|
+
try:
|
|
16
|
+
resp = _http.get(
|
|
17
|
+
"https://api.duckduckgo.com/",
|
|
18
|
+
params={"q": query, "format": "json", "no_redirect": "1"},
|
|
19
|
+
)
|
|
20
|
+
data = resp.json()
|
|
21
|
+
results = []
|
|
22
|
+
|
|
23
|
+
# Abstract (main answer)
|
|
24
|
+
if data.get("Abstract"):
|
|
25
|
+
results.append(f"**{data.get('Heading', 'Answer')}**: {data['Abstract']}")
|
|
26
|
+
if data.get("AbstractURL"):
|
|
27
|
+
results.append(f"Source: {data['AbstractURL']}")
|
|
28
|
+
|
|
29
|
+
# Related topics
|
|
30
|
+
for topic in data.get("RelatedTopics", [])[:max_results]:
|
|
31
|
+
if isinstance(topic, dict) and topic.get("Text"):
|
|
32
|
+
text = topic["Text"][:200]
|
|
33
|
+
url = topic.get("FirstURL", "")
|
|
34
|
+
results.append(f"- {text}" + (f" ({url})" if url else ""))
|
|
35
|
+
|
|
36
|
+
if not results:
|
|
37
|
+
return f"No results found for: {query}"
|
|
38
|
+
|
|
39
|
+
return "\n".join(results)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
return f"Search error: {e}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def fetch_url(url: str) -> str:
|
|
45
|
+
"""Fetch a URL and extract text content (strips HTML tags)."""
|
|
46
|
+
try:
|
|
47
|
+
resp = _http.get(url, headers={"User-Agent": "Clawsy/1.0"})
|
|
48
|
+
content_type = resp.headers.get("content-type", "")
|
|
49
|
+
|
|
50
|
+
if "json" in content_type:
|
|
51
|
+
return json.dumps(resp.json(), indent=2)[:10000]
|
|
52
|
+
|
|
53
|
+
text = resp.text
|
|
54
|
+
if "html" in content_type:
|
|
55
|
+
# Simple HTML to text
|
|
56
|
+
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
|
|
57
|
+
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
|
|
58
|
+
text = re.sub(r"<[^>]+>", " ", text)
|
|
59
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
60
|
+
|
|
61
|
+
# Limit output
|
|
62
|
+
return text[:10000] if len(text) > 10000 else text
|
|
63
|
+
except Exception as e:
|
|
64
|
+
return f"Fetch error: {e}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# OpenAI-compatible tool definitions for function calling
|
|
68
|
+
TOOL_DEFINITIONS = [
|
|
69
|
+
{
|
|
70
|
+
"type": "function",
|
|
71
|
+
"function": {
|
|
72
|
+
"name": "web_search",
|
|
73
|
+
"description": "Search the web for information. Use when you need current data, facts, or research.",
|
|
74
|
+
"parameters": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"query": {"type": "string", "description": "Search query"},
|
|
78
|
+
},
|
|
79
|
+
"required": ["query"],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"type": "function",
|
|
85
|
+
"function": {
|
|
86
|
+
"name": "fetch_url",
|
|
87
|
+
"description": "Fetch and read content from a URL. Use to get specific webpage content.",
|
|
88
|
+
"parameters": {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"url": {"type": "string", "description": "URL to fetch"},
|
|
92
|
+
},
|
|
93
|
+
"required": ["url"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
TOOL_HANDLERS = {
|
|
100
|
+
"web_search": lambda args: web_search(args["query"]),
|
|
101
|
+
"fetch_url": lambda args: fetch_url(args["url"]),
|
|
102
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Worker loop: fetch task → LLM (with tools) → submit → repeat."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
from clawsy.client import HubClient, HubError
|
|
10
|
+
from clawsy.config import Config
|
|
11
|
+
from clawsy.llm import create_client, generate_patch
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_worker(
|
|
17
|
+
cfg: Config,
|
|
18
|
+
task_id: int | None = None,
|
|
19
|
+
max_rounds: int = 0,
|
|
20
|
+
category: str = "",
|
|
21
|
+
) -> None:
|
|
22
|
+
"""Main worker loop.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
cfg: Clawsy config.
|
|
26
|
+
task_id: Specific task to work on, or None to auto-pick.
|
|
27
|
+
max_rounds: Max iterations (0 = infinite).
|
|
28
|
+
category: Filter tasks by category (from agent subscription).
|
|
29
|
+
"""
|
|
30
|
+
hub = HubClient(cfg)
|
|
31
|
+
llm = create_client(cfg.llm)
|
|
32
|
+
use_tools = cfg.tools.web_search or cfg.tools.fetch_url
|
|
33
|
+
round_num = 0
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
while max_rounds == 0 or round_num < max_rounds:
|
|
37
|
+
round_num += 1
|
|
38
|
+
console.print(f"\n[bold cyan]━━━ Round {round_num} ━━━[/bold cyan]")
|
|
39
|
+
|
|
40
|
+
# Pick task
|
|
41
|
+
tid = task_id
|
|
42
|
+
if tid is None:
|
|
43
|
+
tid = _pick_best_task(hub, category)
|
|
44
|
+
if tid is None:
|
|
45
|
+
console.print("[yellow]No open tasks available. Waiting 30s...[/yellow]")
|
|
46
|
+
time.sleep(30)
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Join if needed
|
|
50
|
+
try:
|
|
51
|
+
hub.join_task(tid)
|
|
52
|
+
console.print(f"[green]Joined task {tid}[/green]")
|
|
53
|
+
except HubError as e:
|
|
54
|
+
if e.status != 409: # already joined
|
|
55
|
+
raise
|
|
56
|
+
|
|
57
|
+
# Get task details with enriched prompt
|
|
58
|
+
task_data = hub.get_task(tid, enriched=True)
|
|
59
|
+
task = task_data.get("task", task_data)
|
|
60
|
+
program_md = task.get("program_md", "")
|
|
61
|
+
enriched_prompt = task_data.get("enriched_prompt", "")
|
|
62
|
+
task_category = task.get("category", "")
|
|
63
|
+
|
|
64
|
+
if not program_md:
|
|
65
|
+
console.print(f"[yellow]Task {tid} has no program_md, skipping[/yellow]")
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
title = task.get("title", "unnamed")
|
|
69
|
+
console.print(f"[blue]Working on task {tid}: {title}[/blue]")
|
|
70
|
+
if task_category:
|
|
71
|
+
console.print(f"[dim]Category: {task_category}[/dim]")
|
|
72
|
+
if enriched_prompt:
|
|
73
|
+
console.print("[dim]Using enriched prompt with checklist[/dim]")
|
|
74
|
+
if use_tools:
|
|
75
|
+
console.print("[dim]Tools enabled: web_search, fetch_url[/dim]")
|
|
76
|
+
|
|
77
|
+
# Generate patch via LLM
|
|
78
|
+
console.print("[dim]Generating patch via LLM...[/dim]")
|
|
79
|
+
patch_content = generate_patch(
|
|
80
|
+
llm,
|
|
81
|
+
cfg.llm.model,
|
|
82
|
+
program_md,
|
|
83
|
+
enriched_prompt=enriched_prompt,
|
|
84
|
+
use_tools=use_tools,
|
|
85
|
+
)
|
|
86
|
+
if not patch_content.strip():
|
|
87
|
+
console.print("[yellow]LLM returned empty patch, retrying...[/yellow]")
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
console.print(f"[dim]Patch: {len(patch_content)} chars[/dim]")
|
|
91
|
+
|
|
92
|
+
# Submit patch
|
|
93
|
+
try:
|
|
94
|
+
result = hub.submit_patch(tid, patch_content)
|
|
95
|
+
patch_id = result.get("id", result.get("patch_id", "?"))
|
|
96
|
+
console.print(f"[green]Submitted patch {patch_id}[/green]")
|
|
97
|
+
except HubError as e:
|
|
98
|
+
console.print(f"[red]Submit failed: {e.detail}[/red]")
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Brief pause between rounds
|
|
102
|
+
time.sleep(5)
|
|
103
|
+
|
|
104
|
+
except KeyboardInterrupt:
|
|
105
|
+
console.print("\n[yellow]Worker stopped by user[/yellow]")
|
|
106
|
+
finally:
|
|
107
|
+
hub.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pick_best_task(hub: HubClient, category: str = "") -> int | None:
|
|
111
|
+
"""Pick the open task with highest reward_karma, optionally filtered by category."""
|
|
112
|
+
tasks = hub.list_tasks(status="open", category=category)
|
|
113
|
+
if not tasks:
|
|
114
|
+
return None
|
|
115
|
+
best = max(tasks, key=lambda t: t.get("reward_karma", 0))
|
|
116
|
+
return best["id"]
|
|
File without changes
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tests for config module."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from clawsy.config import Config, LLMConfig
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_config_defaults():
|
|
9
|
+
cfg = Config()
|
|
10
|
+
assert cfg.hub_url == "https://agenthub.clawsy.app"
|
|
11
|
+
assert cfg.api_key == ""
|
|
12
|
+
assert cfg.llm.provider == "openai"
|
|
13
|
+
assert cfg.llm.model == "gpt-4o"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_config_save_load(tmp_path, monkeypatch):
|
|
17
|
+
config_dir = tmp_path / ".clawsy"
|
|
18
|
+
config_file = config_dir / "config.toml"
|
|
19
|
+
|
|
20
|
+
monkeypatch.setattr("clawsy.config.CONFIG_DIR", config_dir)
|
|
21
|
+
monkeypatch.setattr("clawsy.config.CONFIG_FILE", config_file)
|
|
22
|
+
|
|
23
|
+
cfg = Config(
|
|
24
|
+
hub_url="https://test.example.com",
|
|
25
|
+
api_key="test_key_123",
|
|
26
|
+
llm=LLMConfig(
|
|
27
|
+
provider="qwen",
|
|
28
|
+
api_key="sk-test",
|
|
29
|
+
model="qwen3-max",
|
|
30
|
+
base_url="https://test.api.com/v1",
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
cfg.save()
|
|
34
|
+
|
|
35
|
+
assert config_file.exists()
|
|
36
|
+
|
|
37
|
+
loaded = Config.load()
|
|
38
|
+
assert loaded.hub_url == "https://test.example.com"
|
|
39
|
+
assert loaded.api_key == "test_key_123"
|
|
40
|
+
assert loaded.llm.provider == "qwen"
|
|
41
|
+
assert loaded.llm.api_key == "sk-test"
|
|
42
|
+
assert loaded.llm.model == "qwen3-max"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_config_load_missing(tmp_path, monkeypatch):
|
|
46
|
+
monkeypatch.setattr("clawsy.config.CONFIG_FILE", tmp_path / "nonexistent.toml")
|
|
47
|
+
cfg = Config.load()
|
|
48
|
+
assert cfg.hub_url == "https://agenthub.clawsy.app"
|