smalltask 0.2.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.
smalltask/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """smalltask: define tools and agents as code, run them anywhere."""
2
+
3
+
4
+ def tool(fn):
5
+ """Mark a function as a smalltask tool.
6
+
7
+ Only decorated functions are exposed to agents when @tool is used in a file.
8
+ Files without any @tool decorators expose all public functions (backward compat).
9
+
10
+ Usage::
11
+
12
+ from smalltask import tool
13
+
14
+ @tool
15
+ def get_orders(days: int) -> list:
16
+ \"\"\"Return all orders placed in the last N days.\"\"\"
17
+ ...
18
+ """
19
+ fn._smalltask_tool = True
20
+ return fn
smalltask/cli.py ADDED
@@ -0,0 +1,452 @@
1
+ """CLI entrypoint: smalltask run / smalltask init."""
2
+
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from smalltask.runner import run_agent
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Shared: smalltask.yaml with connection presets
11
+ # ---------------------------------------------------------------------------
12
+
13
+ _SMALLTASK_CONFIG = '''\
14
+ # smalltask.yaml — project-level configuration
15
+ #
16
+ # Define named connections here, then reference them in agent YAMLs with:
17
+ # llm:
18
+ # connection: openrouter
19
+ # model: anthropic/claude-sonnet-4-20250514
20
+ #
21
+ # The connection provides the URL, auth, and headers.
22
+ # The agent YAML provides (or overrides) the model and other settings.
23
+
24
+ connections:
25
+ # --- Uncomment and configure the providers you use ---
26
+
27
+ openrouter:
28
+ url: https://openrouter.ai/api/v1/chat/completions
29
+ api_key_env: OPENROUTER_API_KEY
30
+ # extra_headers:
31
+ # HTTP-Referer: https://yoursite.com
32
+
33
+ # ollama:
34
+ # url: http://localhost:11434/v1/chat/completions
35
+
36
+ # groq:
37
+ # url: https://api.groq.com/openai/v1/chat/completions
38
+ # api_key_env: GROQ_API_KEY
39
+
40
+ # together:
41
+ # url: https://api.together.xyz/v1/chat/completions
42
+ # api_key_env: TOGETHER_API_KEY
43
+
44
+ # bedrock:
45
+ # url: https://bedrock-runtime.us-east-1.amazonaws.com/v1/chat/completions
46
+ # api_key_env: AWS_SECRET_ACCESS_KEY
47
+ # extra_headers:
48
+ # X-Amz-Security-Token-Env: AWS_SESSION_TOKEN
49
+ '''
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Default (blank) template
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _EXAMPLE_TOOL = '''\
56
+ """
57
+ Example tools for smalltask.
58
+
59
+ These functions are the security boundary — the agent can only do what these
60
+ functions allow. Replace the stub implementations with real logic.
61
+ """
62
+
63
+ from smalltask import tool
64
+
65
+
66
+ @tool
67
+ def search_records(query: str, limit: int = 10) -> list:
68
+ """Search records matching a query string.
69
+
70
+ Args:
71
+ query: Search term to match against record names and descriptions.
72
+ limit: Maximum number of results to return.
73
+ """
74
+ # TODO: replace with a real data source (DB, API, etc.)
75
+ return [
76
+ {"id": f"REC-{i:04d}", "name": f"Record {i}", "description": f"Matches \'{query}\'"}
77
+ for i in range(1, limit + 1)
78
+ ]
79
+
80
+
81
+ @tool
82
+ def get_summary_stats() -> dict:
83
+ """Return a high-level summary of the current dataset."""
84
+ # TODO: replace with a real query
85
+ return {
86
+ "total_records": 1042,
87
+ "active": 891,
88
+ "pending": 151,
89
+ }
90
+ '''
91
+
92
+ _EXAMPLE_AGENT = '''\
93
+ name: example_agent
94
+ description: Searches records and summarises the dataset.
95
+
96
+ llm:
97
+ connection: openrouter
98
+ model: anthropic/claude-sonnet-4-20250514
99
+ max_tokens: 2048
100
+
101
+ prompt: |
102
+ You are a helpful data analyst.
103
+
104
+ The user wants to understand the current state of the dataset and find
105
+ records related to "$topic".
106
+
107
+ Using the available tools:
108
+ 1. Retrieve summary statistics for the dataset.
109
+ 2. Search for records related to the topic.
110
+ 3. Write a concise report covering what you found.
111
+
112
+ Be direct. Use numbers. No fluff.
113
+
114
+ tools:
115
+ - example.get_summary_stats
116
+ - example.search_records
117
+ '''
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # GitHub template
121
+ # ---------------------------------------------------------------------------
122
+
123
+ _GITHUB_TOOL = '''\
124
+ """
125
+ GitHub tools for smalltask.
126
+
127
+ Read-only tools against the GitHub REST API. Requires a GITHUB_TOKEN
128
+ environment variable with at least `repo:read` scope.
129
+
130
+ Set it in your shell or scheduler:
131
+ export GITHUB_TOKEN=ghp_...
132
+
133
+ These tools are the security boundary — the agent cannot call arbitrary
134
+ GitHub endpoints, only what is exposed here.
135
+ """
136
+
137
+ import os
138
+ from datetime import datetime, timedelta, timezone
139
+
140
+ import httpx
141
+
142
+ from smalltask import tool
143
+
144
+ _BASE = "https://api.github.com"
145
+
146
+
147
+ def _headers() -> dict:
148
+ token = os.environ.get("GITHUB_TOKEN", "")
149
+ h = {
150
+ "Accept": "application/vnd.github+json",
151
+ "X-GitHub-Api-Version": "2022-11-28",
152
+ }
153
+ if token:
154
+ h["Authorization"] = f"Bearer {token}"
155
+ return h
156
+
157
+
158
+ @tool
159
+ def list_open_prs(repo: str, include_drafts: bool = False) -> list:
160
+ """List open pull requests for a GitHub repository.
161
+
162
+ Args:
163
+ repo: Repository in owner/name format, e.g. acme/backend.
164
+ include_drafts: Include draft PRs. Defaults to False.
165
+ """
166
+ resp = httpx.get(
167
+ f"{_BASE}/repos/{repo}/pulls",
168
+ headers=_headers(),
169
+ params={"state": "open", "per_page": 50},
170
+ )
171
+ resp.raise_for_status()
172
+ prs = resp.json()
173
+ if not include_drafts:
174
+ prs = [p for p in prs if not p.get("draft")]
175
+ return [
176
+ {
177
+ "number": p["number"],
178
+ "title": p["title"],
179
+ "author": p["user"]["login"],
180
+ "created_at": p["created_at"],
181
+ "updated_at": p["updated_at"],
182
+ "url": p["html_url"],
183
+ "reviewers_requested": [r["login"] for r in p.get("requested_reviewers", [])],
184
+ }
185
+ for p in prs
186
+ ]
187
+
188
+
189
+ @tool
190
+ def list_recent_merged_prs(repo: str, days: int = 7) -> list:
191
+ """List pull requests merged in the last N days.
192
+
193
+ Args:
194
+ repo: Repository in owner/name format.
195
+ days: Number of days to look back.
196
+ """
197
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
198
+ resp = httpx.get(
199
+ f"{_BASE}/repos/{repo}/pulls",
200
+ headers=_headers(),
201
+ params={"state": "closed", "sort": "updated", "direction": "desc", "per_page": 100},
202
+ )
203
+ resp.raise_for_status()
204
+ return [
205
+ {
206
+ "number": p["number"],
207
+ "title": p["title"],
208
+ "author": p["user"]["login"],
209
+ "merged_at": p["merged_at"],
210
+ "url": p["html_url"],
211
+ }
212
+ for p in resp.json()
213
+ if p.get("merged_at") and p["merged_at"] >= since
214
+ ]
215
+
216
+
217
+ @tool
218
+ def get_pr_review_status(repo: str, pr_number: int) -> dict:
219
+ """Get the current review status of a pull request.
220
+
221
+ Returns who has approved, requested changes, or not yet reviewed.
222
+
223
+ Args:
224
+ repo: Repository in owner/name format.
225
+ pr_number: The PR number.
226
+ """
227
+ resp = httpx.get(
228
+ f"{_BASE}/repos/{repo}/pulls/{pr_number}/reviews",
229
+ headers=_headers(),
230
+ )
231
+ resp.raise_for_status()
232
+
233
+ # Keep only the latest review state per reviewer
234
+ latest: dict[str, str] = {}
235
+ for r in resp.json():
236
+ latest[r["user"]["login"]] = r["state"]
237
+
238
+ return {
239
+ "pr_number": pr_number,
240
+ "approved_by": [u for u, s in latest.items() if s == "APPROVED"],
241
+ "changes_requested_by": [u for u, s in latest.items() if s == "CHANGES_REQUESTED"],
242
+ "commented_by": [u for u, s in latest.items() if s == "COMMENTED"],
243
+ }
244
+
245
+
246
+ @tool
247
+ def list_issues(repo: str, state: str = "open", label: str = "") -> list:
248
+ """List issues for a GitHub repository.
249
+
250
+ Args:
251
+ repo: Repository in owner/name format.
252
+ state: Issue state — open, closed, or all.
253
+ label: Filter by label name. Leave empty to skip filter.
254
+ """
255
+ params: dict = {"state": state, "per_page": 50}
256
+ if label:
257
+ params["labels"] = label
258
+ resp = httpx.get(f"{_BASE}/repos/{repo}/issues", headers=_headers(), params=params)
259
+ resp.raise_for_status()
260
+ return [
261
+ {
262
+ "number": i["number"],
263
+ "title": i["title"],
264
+ "author": i["user"]["login"],
265
+ "labels": [la["name"] for la in i.get("labels", [])],
266
+ "created_at": i["created_at"],
267
+ "url": i["html_url"],
268
+ }
269
+ for i in resp.json()
270
+ if "pull_request" not in i # issues endpoint returns PRs too
271
+ ]
272
+
273
+
274
+ @tool
275
+ def get_workflow_runs(repo: str, workflow: str, conclusion: str = "failure") -> list:
276
+ """Get recent runs for a GitHub Actions workflow.
277
+
278
+ Args:
279
+ repo: Repository in owner/name format.
280
+ workflow: Workflow filename, e.g. ci.yml or publish.yml.
281
+ conclusion: Filter by conclusion — failure, success, cancelled, or all.
282
+ """
283
+ params: dict = {"per_page": 10}
284
+ if conclusion != "all":
285
+ params["status"] = conclusion
286
+ resp = httpx.get(
287
+ f"{_BASE}/repos/{repo}/actions/workflows/{workflow}/runs",
288
+ headers=_headers(),
289
+ params=params,
290
+ )
291
+ resp.raise_for_status()
292
+ return [
293
+ {
294
+ "id": r["id"],
295
+ "conclusion": r["conclusion"],
296
+ "branch": r["head_branch"],
297
+ "commit": r["head_sha"][:7],
298
+ "started_at": r["run_started_at"],
299
+ "url": r["html_url"],
300
+ }
301
+ for r in resp.json().get("workflow_runs", [])
302
+ ]
303
+ '''
304
+
305
+ _GITHUB_AGENT = '''\
306
+ name: github_pr_digest
307
+ description: Weekly digest of open PRs, recent merges, and CI health.
308
+
309
+ llm:
310
+ connection: openrouter
311
+ model: anthropic/claude-sonnet-4-20250514
312
+ max_tokens: 2048
313
+
314
+ prompt: |
315
+ You are an engineering lead reviewing the state of the $repo GitHub repository.
316
+
317
+ Produce a concise weekly digest covering:
318
+ 1. Open PRs — who is waiting on review, who is blocked, how long PRs have been open
319
+ 2. Merged this week — what shipped
320
+ 3. CI health — any workflow failures on main
321
+ 4. Action items — specific people who should review specific PRs
322
+
323
+ Be direct. Use names and numbers. No fluff.
324
+
325
+ tools:
326
+ - github.list_open_prs
327
+ - github.list_recent_merged_prs
328
+ - github.get_pr_review_status
329
+ - github.get_workflow_runs
330
+ - github.list_issues
331
+ '''
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Template registry
335
+ # ---------------------------------------------------------------------------
336
+
337
+ _TEMPLATES: dict[str, tuple[str, str, str, str, str]] = {
338
+ # name: (tool_filename, tool_content, agent_filename, agent_content, hint)
339
+ "default": (
340
+ "example.py",
341
+ _EXAMPLE_TOOL,
342
+ "example.yaml",
343
+ _EXAMPLE_AGENT,
344
+ "smalltask run agents/example.yaml --var topic=<your topic> --verbose",
345
+ ),
346
+ "github": (
347
+ "github.py",
348
+ _GITHUB_TOOL,
349
+ "github_pr_digest.yaml",
350
+ _GITHUB_AGENT,
351
+ "export GITHUB_TOKEN=ghp_...\nsmalltask run agents/github_pr_digest.yaml --var repo=owner/name --verbose",
352
+ ),
353
+ }
354
+
355
+
356
+ # ---------------------------------------------------------------------------
357
+ # CLI
358
+ # ---------------------------------------------------------------------------
359
+
360
+ @click.group()
361
+ def cli():
362
+ """smalltask: define tools and agents as code, run them anywhere."""
363
+
364
+
365
+ @cli.command()
366
+ @click.argument("agent", type=click.Path(exists=True, path_type=Path))
367
+ @click.option("--tools-dir", "-t", type=click.Path(path_type=Path), default=None,
368
+ help="Directory containing tool Python files. Auto-detected if not set.")
369
+ @click.option("--var", "-v", multiple=True, metavar="KEY=VALUE",
370
+ help="Input variables to interpolate into the prompt. Repeatable.")
371
+ @click.option("--verbose", is_flag=True, help="Print tool calls and results.")
372
+ def run(agent: Path, tools_dir: Path | None, var: tuple[str, ...], verbose: bool):
373
+ """Run an agent defined by a YAML file.
374
+
375
+ Example:
376
+
377
+ smalltask run agents/weekly_review.yaml --var week=2024-W01
378
+ """
379
+ input_vars = {}
380
+ for v in var:
381
+ if "=" not in v:
382
+ raise click.BadParameter(f"Expected KEY=VALUE, got: {v}", param_hint="--var")
383
+ k, val = v.split("=", 1)
384
+ input_vars[k] = val
385
+
386
+ result = run_agent(agent, tools_dir=tools_dir, input_vars=input_vars or None, verbose=verbose)
387
+ click.echo(result)
388
+
389
+
390
+ @cli.command()
391
+ @click.argument("directory", type=click.Path(path_type=Path), default=".")
392
+ @click.option(
393
+ "--template", "-t",
394
+ type=click.Choice(list(_TEMPLATES.keys())),
395
+ default="default",
396
+ show_default=True,
397
+ help="Starter template to scaffold.",
398
+ )
399
+ @click.option("--list", "list_templates", is_flag=True, help="List available templates and exit.")
400
+ def init(directory: Path, template: str, list_templates: bool):
401
+ """Scaffold a new smalltask project in DIRECTORY (default: current directory).
402
+
403
+ \b
404
+ Examples:
405
+ smalltask init
406
+ smalltask init --template github
407
+ smalltask init my-project/ --template github
408
+ smalltask init --list
409
+ """
410
+ if list_templates:
411
+ click.echo("Available templates:\n")
412
+ for name in _TEMPLATES:
413
+ tool_file, _, agent_file, _, _ = _TEMPLATES[name]
414
+ click.echo(f" {name:<12} tools/{tool_file} + agents/{agent_file}")
415
+ return
416
+
417
+ directory = directory.resolve()
418
+ tool_filename, tool_content, agent_filename, agent_content, hint = _TEMPLATES[template]
419
+
420
+ tools_dir = directory / "tools"
421
+ agents_dir = directory / "agents"
422
+ tools_dir.mkdir(parents=True, exist_ok=True)
423
+ agents_dir.mkdir(parents=True, exist_ok=True)
424
+
425
+ tool_file = tools_dir / tool_filename
426
+ agent_file = agents_dir / agent_filename
427
+ config_file = directory / "smalltask.yaml"
428
+ created = []
429
+
430
+ if not config_file.exists():
431
+ config_file.write_text(_SMALLTASK_CONFIG)
432
+ created.append("smalltask.yaml")
433
+ else:
434
+ click.echo(f" skip smalltask.yaml (already exists)")
435
+
436
+ if not tool_file.exists():
437
+ tool_file.write_text(tool_content)
438
+ created.append(str(tool_file.relative_to(directory)))
439
+ else:
440
+ click.echo(f" skip {tool_file.relative_to(directory)} (already exists)")
441
+
442
+ if not agent_file.exists():
443
+ agent_file.write_text(agent_content)
444
+ created.append(str(agent_file.relative_to(directory)))
445
+ else:
446
+ click.echo(f" skip {agent_file.relative_to(directory)} (already exists)")
447
+
448
+ for path in created:
449
+ click.echo(f" create {path}")
450
+
451
+ if created:
452
+ click.echo(f"\nDone. Next steps:\n\n {hint}\n")
smalltask/llm.py ADDED
@@ -0,0 +1,80 @@
1
+ """
2
+ Raw HTTP LLM client — no SDK, no provider code.
3
+
4
+ Expects an OpenAI-compatible chat completions endpoint.
5
+ Works with OpenRouter, Ollama, Groq, Together, Bedrock (via their compat layer),
6
+ Gemini (via their compat layer), or anything else that speaks the format.
7
+
8
+ Agent YAML config:
9
+ llm:
10
+ url: https://openrouter.ai/api/v1/chat/completions
11
+ model: anthropic/claude-opus-4-6
12
+ api_key_env: OPENROUTER_API_KEY # env var name, not the key itself
13
+ max_tokens: 4096 # optional
14
+ extra_headers: # optional
15
+ HTTP-Referer: https://yoursite.com
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+
25
+ def complete(messages: list[dict], llm_config: dict) -> tuple[str, dict]:
26
+ """
27
+ Send messages to the configured LLM endpoint and return (response_text, usage).
28
+
29
+ usage dict contains prompt_tokens, completion_tokens, total_tokens (0 if not reported).
30
+
31
+ messages: list of {"role": "user"|"assistant"|"system", "content": "..."}
32
+ llm_config: dict from agent YAML's `llm` key
33
+ """
34
+ url = llm_config.get("url")
35
+ if not url:
36
+ raise ValueError("llm.url is required in agent config")
37
+
38
+ model = llm_config.get("model")
39
+ if not model:
40
+ raise ValueError("llm.model is required in agent config")
41
+
42
+ api_key_env = llm_config.get("api_key_env")
43
+ api_key = os.environ.get(api_key_env, "") if api_key_env else ""
44
+
45
+ headers: dict[str, str] = {"Content-Type": "application/json"}
46
+ if api_key:
47
+ headers["Authorization"] = f"Bearer {api_key}"
48
+
49
+ extra_headers = llm_config.get("extra_headers", {})
50
+ headers.update(extra_headers)
51
+
52
+ payload: dict[str, Any] = {
53
+ "model": model,
54
+ "messages": messages,
55
+ "max_tokens": llm_config.get("max_tokens", 4096),
56
+ }
57
+
58
+ timeout = llm_config.get("timeout", 120)
59
+
60
+ response = httpx.post(url, headers=headers, json=payload, timeout=timeout)
61
+
62
+ if response.status_code != 200:
63
+ raise RuntimeError(
64
+ f"LLM request failed ({response.status_code}): {response.text[:500]}"
65
+ )
66
+
67
+ data = response.json()
68
+
69
+ try:
70
+ text = data["choices"][0]["message"]["content"]
71
+ except (KeyError, IndexError) as e:
72
+ raise RuntimeError(f"Unexpected LLM response shape: {e}\n{json.dumps(data)[:500]}")
73
+
74
+ raw_usage = data.get("usage", {})
75
+ usage = {
76
+ "prompt_tokens": raw_usage.get("prompt_tokens", 0),
77
+ "completion_tokens": raw_usage.get("completion_tokens", 0),
78
+ "total_tokens": raw_usage.get("total_tokens", 0),
79
+ }
80
+ return text, usage