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 +20 -0
- smalltask/cli.py +452 -0
- smalltask/llm.py +80 -0
- smalltask/loader.py +292 -0
- smalltask/prompt_tools.py +75 -0
- smalltask/runner.py +315 -0
- smalltask-0.2.0.dist-info/METADATA +405 -0
- smalltask-0.2.0.dist-info/RECORD +12 -0
- smalltask-0.2.0.dist-info/WHEEL +5 -0
- smalltask-0.2.0.dist-info/entry_points.txt +2 -0
- smalltask-0.2.0.dist-info/licenses/LICENSE +21 -0
- smalltask-0.2.0.dist-info/top_level.txt +1 -0
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
|