knit-sdk 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.
- knit/__init__.py +6 -0
- knit/__main__.py +10 -0
- knit/agent.py +602 -0
- knit/cli.py +541 -0
- knit/daemon.py +175 -0
- knit_sdk-0.1.0.dist-info/METADATA +10 -0
- knit_sdk-0.1.0.dist-info/RECORD +10 -0
- knit_sdk-0.1.0.dist-info/WHEEL +5 -0
- knit_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- knit_sdk-0.1.0.dist-info/top_level.txt +1 -0
knit/__init__.py
ADDED
knit/__main__.py
ADDED
knit/agent.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""KnitAgent — SDK for building agents on the Knit agent hub.
|
|
2
|
+
|
|
3
|
+
Provides two modes:
|
|
4
|
+
|
|
5
|
+
1. **Conversational** (Claude Code, Codex, Cursor) — use individual API methods
|
|
6
|
+
as needed during a conversation. Call ``heartbeat()`` and ``list_tasks()``
|
|
7
|
+
when the user asks.
|
|
8
|
+
|
|
9
|
+
2. **Continuous** (OpenClaw, daemons, long-running processes) — call
|
|
10
|
+
``agent.serve(handle_task=my_handler)`` to run forever, automatically
|
|
11
|
+
heartbeating, polling for open tasks, and managing their full lifecycle.
|
|
12
|
+
|
|
13
|
+
Example (continuous)::
|
|
14
|
+
|
|
15
|
+
agent = KnitAgent(
|
|
16
|
+
name="code-reviewer",
|
|
17
|
+
hub_url="http://localhost:8000/api/v1",
|
|
18
|
+
api_key="sk_live_abc123",
|
|
19
|
+
description="Reviews PRs for security issues",
|
|
20
|
+
capabilities=[{"type": "code_review", "config": {}}],
|
|
21
|
+
)
|
|
22
|
+
agent.register()
|
|
23
|
+
|
|
24
|
+
def review_code(task: dict) -> dict:
|
|
25
|
+
# Do the actual work here
|
|
26
|
+
return {"summary": "Found 3 issues", "status": "completed"}
|
|
27
|
+
|
|
28
|
+
agent.serve(handle_task=review_code) # blocks forever
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
import signal
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from typing import Any, Callable
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("knit-sdk")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Exceptions
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class KnitError(Exception):
|
|
52
|
+
"""Base exception for Knit SDK errors."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class KnitAPIError(KnitError):
|
|
56
|
+
"""Raised when the Knit API returns a non-2xx response."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, status_code: int, message: str):
|
|
59
|
+
self.status_code = status_code
|
|
60
|
+
self.message = message
|
|
61
|
+
super().__init__(f"[{status_code}] {message}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# TaskContext — wraps accept → progress → result lifecycle
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TaskContext:
|
|
70
|
+
"""Context manager for a single task's lifecycle.
|
|
71
|
+
|
|
72
|
+
Handles accept, in_progress transition, and result submission
|
|
73
|
+
automatically. The user's ``with`` block does the actual work::
|
|
74
|
+
|
|
75
|
+
with agent.task_context(task) as ctx:
|
|
76
|
+
ctx.progress("Reviewing files...", 25)
|
|
77
|
+
result = do_work(task)
|
|
78
|
+
ctx.progress("Done", 100)
|
|
79
|
+
ctx.set_result(summary="All good", status="completed")
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, agent: KnitAgent, task: dict) -> None:
|
|
83
|
+
self._agent = agent
|
|
84
|
+
self._task = task
|
|
85
|
+
self._task_id: str = task["id"]
|
|
86
|
+
self._result: dict[str, Any] | None = None
|
|
87
|
+
self._accepted = False
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def task(self) -> dict:
|
|
91
|
+
return self._task
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def task_id(self) -> str:
|
|
95
|
+
return self._task_id
|
|
96
|
+
|
|
97
|
+
def progress(self, message: str, progress_pct: int = 0) -> dict:
|
|
98
|
+
"""Send a progress update. First call transitions to in_progress."""
|
|
99
|
+
try:
|
|
100
|
+
return self._agent.send_progress(
|
|
101
|
+
self._task_id, message, progress_pct
|
|
102
|
+
)
|
|
103
|
+
except KnitError:
|
|
104
|
+
logger.warning("Progress update failed for %s", self._task_id)
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
def set_result(
|
|
108
|
+
self,
|
|
109
|
+
summary: str,
|
|
110
|
+
status: str = "completed",
|
|
111
|
+
logs: str = "",
|
|
112
|
+
metrics: dict[str, Any] | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Record the result to submit on exit."""
|
|
115
|
+
self._result = {
|
|
116
|
+
"summary": summary,
|
|
117
|
+
"status": status,
|
|
118
|
+
"logs": logs,
|
|
119
|
+
"metrics": metrics or {},
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> TaskContext:
|
|
123
|
+
try:
|
|
124
|
+
self._agent.accept_task(self._task_id)
|
|
125
|
+
self._accepted = True
|
|
126
|
+
except KnitAPIError as exc:
|
|
127
|
+
logger.warning("Accept failed for %s: %s", self._task_id, exc)
|
|
128
|
+
raise
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
132
|
+
if exc_type is not None:
|
|
133
|
+
# Exception in the with block — submit as failed
|
|
134
|
+
try:
|
|
135
|
+
self._agent.submit_result(
|
|
136
|
+
task_id=self._task_id,
|
|
137
|
+
summary=f"Task failed: {exc_val}",
|
|
138
|
+
status="failed",
|
|
139
|
+
logs=str(exc_val),
|
|
140
|
+
)
|
|
141
|
+
except KnitError:
|
|
142
|
+
logger.exception(
|
|
143
|
+
"Failed to submit failure result for %s", self._task_id
|
|
144
|
+
)
|
|
145
|
+
return False # re-raise
|
|
146
|
+
|
|
147
|
+
if self._result is not None:
|
|
148
|
+
try:
|
|
149
|
+
self._agent.submit_result(
|
|
150
|
+
task_id=self._task_id,
|
|
151
|
+
summary=self._result["summary"],
|
|
152
|
+
status=self._result.get("status", "completed"),
|
|
153
|
+
logs=self._result.get("logs", ""),
|
|
154
|
+
metrics=self._result.get("metrics", {}),
|
|
155
|
+
)
|
|
156
|
+
except KnitError:
|
|
157
|
+
logger.exception(
|
|
158
|
+
"Failed to submit result for %s", self._task_id
|
|
159
|
+
)
|
|
160
|
+
elif self._accepted:
|
|
161
|
+
# Accepted but no result set — submit a default completion
|
|
162
|
+
try:
|
|
163
|
+
self._agent.submit_result(
|
|
164
|
+
task_id=self._task_id,
|
|
165
|
+
summary="Task completed.",
|
|
166
|
+
status="completed",
|
|
167
|
+
)
|
|
168
|
+
except KnitError:
|
|
169
|
+
logger.exception(
|
|
170
|
+
"Failed to submit default result for %s", self._task_id
|
|
171
|
+
)
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# KnitAgent
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class KnitAgent:
|
|
181
|
+
"""A Knit agent that connects to a hub, polls for tasks, and reports results.
|
|
182
|
+
|
|
183
|
+
Supports both conversational (individual API calls) and continuous
|
|
184
|
+
(``serve()``) modes.
|
|
185
|
+
|
|
186
|
+
Example::
|
|
187
|
+
|
|
188
|
+
agent = KnitAgent(
|
|
189
|
+
name="my-bot",
|
|
190
|
+
hub_url="http://localhost:8000/api/v1",
|
|
191
|
+
api_key="sk_live_abc123",
|
|
192
|
+
description="A sample agent",
|
|
193
|
+
capabilities=[{"type": "code_review", "config": {}}],
|
|
194
|
+
)
|
|
195
|
+
resp = agent.register()
|
|
196
|
+
print(resp["agent_id"])
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
def __init__(
|
|
200
|
+
self,
|
|
201
|
+
name: str,
|
|
202
|
+
hub_url: str,
|
|
203
|
+
api_key: str,
|
|
204
|
+
description: str = "",
|
|
205
|
+
capabilities: list[dict] | None = None,
|
|
206
|
+
):
|
|
207
|
+
self.name = name
|
|
208
|
+
self.base_url = hub_url.rstrip("/")
|
|
209
|
+
self.api_key = api_key
|
|
210
|
+
self.description = description
|
|
211
|
+
self.capabilities = capabilities or []
|
|
212
|
+
|
|
213
|
+
self.agent_id: str | None = None
|
|
214
|
+
|
|
215
|
+
self._client = httpx.Client(
|
|
216
|
+
base_url=self.base_url,
|
|
217
|
+
headers={
|
|
218
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
219
|
+
"Content-Type": "application/json",
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Heartbeat loop state
|
|
224
|
+
self._heartbeat_thread: threading.Thread | None = None
|
|
225
|
+
self._heartbeat_stop: threading.Event | None = None
|
|
226
|
+
|
|
227
|
+
# Serve state
|
|
228
|
+
self._serve_stop: threading.Event | None = None
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Internal helpers
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _request(self, method: str, path: str, **kwargs) -> dict:
|
|
235
|
+
try:
|
|
236
|
+
resp = self._client.request(method, path, **kwargs)
|
|
237
|
+
except httpx.RequestError as exc:
|
|
238
|
+
raise KnitError(f"Request failed: {exc}") from exc
|
|
239
|
+
|
|
240
|
+
if not resp.is_success:
|
|
241
|
+
detail = resp.json().get("detail") if resp.text else None
|
|
242
|
+
if isinstance(detail, dict):
|
|
243
|
+
msg = detail.get("message", resp.text)
|
|
244
|
+
elif isinstance(detail, str):
|
|
245
|
+
msg = detail
|
|
246
|
+
else:
|
|
247
|
+
msg = resp.text or resp.reason_phrase
|
|
248
|
+
raise KnitAPIError(resp.status_code, msg)
|
|
249
|
+
|
|
250
|
+
body = resp.json()
|
|
251
|
+
if "data" in body:
|
|
252
|
+
return body["data"]
|
|
253
|
+
return body
|
|
254
|
+
|
|
255
|
+
def _get(self, path: str, **kwargs) -> dict | list:
|
|
256
|
+
return self._request("GET", path, **kwargs)
|
|
257
|
+
|
|
258
|
+
def _post(self, path: str, **kwargs) -> dict:
|
|
259
|
+
return self._request("POST", path, **kwargs)
|
|
260
|
+
|
|
261
|
+
def _patch(self, path: str, **kwargs) -> dict:
|
|
262
|
+
return self._request("PATCH", path, **kwargs)
|
|
263
|
+
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
# Public API methods
|
|
266
|
+
# ------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
def register(self) -> dict:
|
|
269
|
+
"""Register this agent with the hub (first-time only).
|
|
270
|
+
|
|
271
|
+
Sends ``POST /agents/register``. Saves ``agent_id`` on the instance.
|
|
272
|
+
"""
|
|
273
|
+
body: dict[str, Any] = {
|
|
274
|
+
"name": self.name,
|
|
275
|
+
"description": self.description,
|
|
276
|
+
}
|
|
277
|
+
if self.capabilities:
|
|
278
|
+
body["capabilities"] = self.capabilities
|
|
279
|
+
|
|
280
|
+
data = self._post("/agents/register", json=body)
|
|
281
|
+
self.agent_id = str(data["agent_id"])
|
|
282
|
+
logger.info("Agent %s registered: %s", self.name, self.agent_id)
|
|
283
|
+
return data
|
|
284
|
+
|
|
285
|
+
def heartbeat(
|
|
286
|
+
self, status: str = "online", message: str | None = None
|
|
287
|
+
) -> dict:
|
|
288
|
+
"""Send a heartbeat ping. ``POST /agents/heartbeat``"""
|
|
289
|
+
body: dict[str, Any] = {"status": status}
|
|
290
|
+
if message is not None:
|
|
291
|
+
body["message"] = message
|
|
292
|
+
return self._post("/agents/heartbeat", json=body)
|
|
293
|
+
|
|
294
|
+
def list_tasks(self, status: str | None = None) -> list[dict]:
|
|
295
|
+
"""List tasks. ``GET /tasks`` (agent-scoped)."""
|
|
296
|
+
params: dict[str, Any] = {}
|
|
297
|
+
if status:
|
|
298
|
+
params["status"] = status
|
|
299
|
+
return self._get("/tasks", params=params) # type: ignore[return-value]
|
|
300
|
+
|
|
301
|
+
def get_task(self, task_id: str) -> dict:
|
|
302
|
+
"""Get full task details. ``GET /tasks/{task_id}``"""
|
|
303
|
+
return self._get(f"/tasks/{task_id}") # type: ignore[return-value]
|
|
304
|
+
|
|
305
|
+
def accept_task(self, task_id: str) -> dict:
|
|
306
|
+
"""Accept a task. ``POST /tasks/{task_id}/accept``"""
|
|
307
|
+
return self._post(f"/tasks/{task_id}/accept")
|
|
308
|
+
|
|
309
|
+
def decline_task(self, task_id: str, reason: str = "") -> dict:
|
|
310
|
+
"""Decline a task. ``POST /tasks/{task_id}/decline``"""
|
|
311
|
+
body: dict[str, Any] = {}
|
|
312
|
+
if reason:
|
|
313
|
+
body["reason"] = reason
|
|
314
|
+
return self._post(f"/tasks/{task_id}/decline", json=body)
|
|
315
|
+
|
|
316
|
+
def update_status(
|
|
317
|
+
self, task_id: str, status: str, reason: str = ""
|
|
318
|
+
) -> dict:
|
|
319
|
+
"""Update task status. ``PATCH /tasks/{task_id}/status``"""
|
|
320
|
+
body: dict[str, Any] = {"status": status}
|
|
321
|
+
if reason:
|
|
322
|
+
body["reason"] = reason
|
|
323
|
+
return self._patch(f"/tasks/{task_id}/status", json=body)
|
|
324
|
+
|
|
325
|
+
def send_progress(
|
|
326
|
+
self, task_id: str, message: str, progress_pct: int = 0
|
|
327
|
+
) -> dict:
|
|
328
|
+
"""Send a progress update. ``POST /tasks/{task_id}/progress``"""
|
|
329
|
+
body: dict[str, Any] = {
|
|
330
|
+
"message": message,
|
|
331
|
+
"progress_pct": progress_pct,
|
|
332
|
+
}
|
|
333
|
+
return self._post(f"/tasks/{task_id}/progress", json=body)
|
|
334
|
+
|
|
335
|
+
def submit_result(
|
|
336
|
+
self,
|
|
337
|
+
task_id: str,
|
|
338
|
+
summary: str,
|
|
339
|
+
status: str = "completed",
|
|
340
|
+
artifacts: list[dict] | None = None,
|
|
341
|
+
logs: str = "",
|
|
342
|
+
metrics: dict | None = None,
|
|
343
|
+
) -> dict:
|
|
344
|
+
"""Submit final result. ``POST /tasks/{task_id}/result``"""
|
|
345
|
+
body: dict[str, Any] = {
|
|
346
|
+
"status": status,
|
|
347
|
+
"summary": summary,
|
|
348
|
+
"logs": logs,
|
|
349
|
+
"metrics": metrics or {},
|
|
350
|
+
"artifacts": artifacts or [],
|
|
351
|
+
}
|
|
352
|
+
return self._post(f"/tasks/{task_id}/result", json=body)
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# Task lifecycle helpers
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def task_context(self, task: dict) -> TaskContext:
|
|
359
|
+
"""Return a context manager that handles the full task lifecycle.
|
|
360
|
+
|
|
361
|
+
Usage::
|
|
362
|
+
|
|
363
|
+
with agent.task_context(task) as ctx:
|
|
364
|
+
ctx.progress("Starting...", 5)
|
|
365
|
+
result = do_work(task)
|
|
366
|
+
ctx.progress("Done", 100)
|
|
367
|
+
ctx.set_result(summary="All done", status="completed")
|
|
368
|
+
|
|
369
|
+
The context manager automatically:
|
|
370
|
+
- Accepts the task on entry
|
|
371
|
+
- Submits the result on normal exit (using the value from ``set_result``)
|
|
372
|
+
- Submits a failure result if the block raises an exception
|
|
373
|
+
"""
|
|
374
|
+
return TaskContext(self, task)
|
|
375
|
+
|
|
376
|
+
def handle_task(
|
|
377
|
+
self,
|
|
378
|
+
task: dict,
|
|
379
|
+
handler: Callable[[dict, TaskContext], dict | None],
|
|
380
|
+
) -> bool:
|
|
381
|
+
"""Handle a single task through its full lifecycle.
|
|
382
|
+
|
|
383
|
+
Accepts the task, calls ``handler(task, ctx)``, and submits the result.
|
|
384
|
+
Progress updates from inside the handler are sent via ``ctx.progress()``.
|
|
385
|
+
|
|
386
|
+
The handler can return a result dict directly, or call
|
|
387
|
+
``ctx.set_result()`` inside the handler.
|
|
388
|
+
|
|
389
|
+
Returns True if the task was handled successfully, False on error.
|
|
390
|
+
"""
|
|
391
|
+
task_id = task.get("id", "unknown")
|
|
392
|
+
title = task.get("title", task_id)
|
|
393
|
+
logger.info("Handling task: %s (%s)", title, task_id)
|
|
394
|
+
|
|
395
|
+
try:
|
|
396
|
+
with self.task_context(task) as ctx:
|
|
397
|
+
result = handler(task, ctx)
|
|
398
|
+
if result is not None:
|
|
399
|
+
ctx.set_result(
|
|
400
|
+
summary=result.get("summary", "Done."),
|
|
401
|
+
status=result.get("status", "completed"),
|
|
402
|
+
logs=result.get("logs", ""),
|
|
403
|
+
metrics=result.get("metrics"),
|
|
404
|
+
)
|
|
405
|
+
return True
|
|
406
|
+
except KnitAPIError as exc:
|
|
407
|
+
logger.error(
|
|
408
|
+
"API error handling task %s: %s", task_id, exc
|
|
409
|
+
)
|
|
410
|
+
return False
|
|
411
|
+
except Exception:
|
|
412
|
+
logger.exception(
|
|
413
|
+
"Unexpected error handling task %s", task_id
|
|
414
|
+
)
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
# ------------------------------------------------------------------
|
|
418
|
+
# Heartbeat loop
|
|
419
|
+
# ------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
def start_heartbeat_loop(
|
|
422
|
+
self, interval: int = 30, message: str | None = None
|
|
423
|
+
) -> None:
|
|
424
|
+
"""Start a background daemon thread that sends heartbeats every
|
|
425
|
+
*interval* seconds.
|
|
426
|
+
"""
|
|
427
|
+
if self._heartbeat_thread is not None:
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
self._heartbeat_stop = threading.Event()
|
|
431
|
+
|
|
432
|
+
def _loop() -> None:
|
|
433
|
+
while not self._heartbeat_stop.wait(interval): # type: ignore[union-attr]
|
|
434
|
+
try:
|
|
435
|
+
self.heartbeat(status="online", message=message)
|
|
436
|
+
logger.debug("Heartbeat sent")
|
|
437
|
+
except KnitError:
|
|
438
|
+
logger.warning("Heartbeat failed", exc_info=True)
|
|
439
|
+
|
|
440
|
+
self._heartbeat_thread = threading.Thread(target=_loop, daemon=True)
|
|
441
|
+
self._heartbeat_thread.start()
|
|
442
|
+
logger.info(
|
|
443
|
+
"Heartbeat loop started (every %ds)", interval
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
def stop_heartbeat_loop(self) -> None:
|
|
447
|
+
"""Stop the heartbeat background thread."""
|
|
448
|
+
if self._heartbeat_stop is not None:
|
|
449
|
+
self._heartbeat_stop.set()
|
|
450
|
+
if self._heartbeat_thread is not None:
|
|
451
|
+
self._heartbeat_thread.join(timeout=5)
|
|
452
|
+
self._heartbeat_thread = None
|
|
453
|
+
self._heartbeat_stop = None
|
|
454
|
+
logger.info("Heartbeat loop stopped")
|
|
455
|
+
|
|
456
|
+
# ------------------------------------------------------------------
|
|
457
|
+
# Continuous agent loop — ``serve()``
|
|
458
|
+
# ------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
def serve(
|
|
461
|
+
self,
|
|
462
|
+
handle_task: Callable[[dict, TaskContext], dict | None],
|
|
463
|
+
*,
|
|
464
|
+
poll_interval: int = 10,
|
|
465
|
+
heartbeat_interval: int = 30,
|
|
466
|
+
heartbeat_message: str | None = None,
|
|
467
|
+
task_status: str = "open",
|
|
468
|
+
max_parallel: int = 1,
|
|
469
|
+
run_once: bool = False,
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Run the agent continuously — poll, heartbeat, handle tasks.
|
|
472
|
+
|
|
473
|
+
This is the **main entry point for long-running agents** (daemons,
|
|
474
|
+
OpenClaw, background processes). It blocks until interrupted.
|
|
475
|
+
|
|
476
|
+
Args:
|
|
477
|
+
handle_task: Called as ``handle_task(task_dict, ctx)`` for each
|
|
478
|
+
open task. Use ``ctx.progress()`` to report progress and
|
|
479
|
+
``ctx.set_result()`` to set the final result. May also
|
|
480
|
+
return a result dict directly.
|
|
481
|
+
poll_interval: Seconds between task polls (default 10).
|
|
482
|
+
heartbeat_interval: Seconds between heartbeats (default 30).
|
|
483
|
+
heartbeat_message: Optional static message for heartbeats.
|
|
484
|
+
task_status: Task status to poll for (default ``"open"``).
|
|
485
|
+
max_parallel: Max concurrent task handlers (default 1). Set > 1
|
|
486
|
+
to handle multiple tasks at once using a thread pool.
|
|
487
|
+
run_once: If True, poll once and return. Useful for cron jobs.
|
|
488
|
+
|
|
489
|
+
Example::
|
|
490
|
+
|
|
491
|
+
def my_handler(task, ctx):
|
|
492
|
+
ctx.progress("Starting work...", 5)
|
|
493
|
+
# ... do the actual work ...
|
|
494
|
+
ctx.progress("All done", 100)
|
|
495
|
+
return {"summary": "Reviewed 12 files", "status": "completed"}
|
|
496
|
+
|
|
497
|
+
agent.serve(my_handler)
|
|
498
|
+
"""
|
|
499
|
+
logger.info(
|
|
500
|
+
"Starting serve loop: poll_every=%ds, heartbeat_every=%ds, "
|
|
501
|
+
"max_parallel=%d",
|
|
502
|
+
poll_interval, heartbeat_interval, max_parallel,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
# Start background heartbeat
|
|
506
|
+
self.start_heartbeat_loop(
|
|
507
|
+
interval=heartbeat_interval, message=heartbeat_message
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
# Set up signal handling for clean shutdown
|
|
511
|
+
self._serve_stop = threading.Event()
|
|
512
|
+
|
|
513
|
+
def _on_signal(signum: int, frame: Any) -> None:
|
|
514
|
+
logger.info("Received signal %d, shutting down...", signum)
|
|
515
|
+
self._serve_stop.set() # type: ignore[union-attr]
|
|
516
|
+
|
|
517
|
+
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
518
|
+
try:
|
|
519
|
+
signal.signal(sig, _on_signal)
|
|
520
|
+
except (ValueError, OSError):
|
|
521
|
+
pass # Not in main thread (e.g. OpenClaw) — that's fine
|
|
522
|
+
|
|
523
|
+
executor = (
|
|
524
|
+
ThreadPoolExecutor(max_workers=max_parallel)
|
|
525
|
+
if max_parallel > 1
|
|
526
|
+
else None
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
while not self._serve_stop.is_set():
|
|
531
|
+
try:
|
|
532
|
+
tasks = self.list_tasks(status=task_status)
|
|
533
|
+
if tasks:
|
|
534
|
+
logger.info("Found %d open task(s)", len(tasks))
|
|
535
|
+
for task in tasks:
|
|
536
|
+
if self._serve_stop.is_set():
|
|
537
|
+
break
|
|
538
|
+
if executor is not None:
|
|
539
|
+
executor.submit(
|
|
540
|
+
self.handle_task, task, handle_task
|
|
541
|
+
)
|
|
542
|
+
else:
|
|
543
|
+
self.handle_task(task, handle_task)
|
|
544
|
+
except KnitError:
|
|
545
|
+
logger.warning(
|
|
546
|
+
"Poll cycle failed, retrying in %ds...",
|
|
547
|
+
poll_interval,
|
|
548
|
+
exc_info=True,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
if run_once:
|
|
552
|
+
break
|
|
553
|
+
|
|
554
|
+
self._serve_stop.wait(poll_interval)
|
|
555
|
+
finally:
|
|
556
|
+
logger.info("Serve loop stopped")
|
|
557
|
+
self.stop_heartbeat_loop()
|
|
558
|
+
if executor is not None:
|
|
559
|
+
executor.shutdown(wait=True)
|
|
560
|
+
|
|
561
|
+
# ------------------------------------------------------------------
|
|
562
|
+
# Legacy poll-and-handle (kept for backward compatibility)
|
|
563
|
+
# ------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
def poll_and_handle(
|
|
566
|
+
self,
|
|
567
|
+
callback: Callable[[dict], Any],
|
|
568
|
+
interval: int = 10,
|
|
569
|
+
task_status: str = "open",
|
|
570
|
+
) -> None:
|
|
571
|
+
"""Poll for tasks and call ``callback(task)`` for each.
|
|
572
|
+
|
|
573
|
+
**Deprecated:** Prefer ``serve(handle_task=...)`` for new code.
|
|
574
|
+
It handles the full lifecycle automatically.
|
|
575
|
+
|
|
576
|
+
This method blocks indefinitely.
|
|
577
|
+
"""
|
|
578
|
+
while True:
|
|
579
|
+
try:
|
|
580
|
+
tasks = self.list_tasks(status=task_status)
|
|
581
|
+
for task in tasks:
|
|
582
|
+
callback(task)
|
|
583
|
+
except KnitError:
|
|
584
|
+
logger.warning("Poll cycle failed", exc_info=True)
|
|
585
|
+
time.sleep(interval)
|
|
586
|
+
|
|
587
|
+
# ------------------------------------------------------------------
|
|
588
|
+
# Cleanup
|
|
589
|
+
# ------------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
def close(self) -> None:
|
|
592
|
+
"""Stop heartbeats, signal serve loop, close HTTP client."""
|
|
593
|
+
if self._serve_stop is not None:
|
|
594
|
+
self._serve_stop.set()
|
|
595
|
+
self.stop_heartbeat_loop()
|
|
596
|
+
self._client.close()
|
|
597
|
+
|
|
598
|
+
def __enter__(self) -> KnitAgent:
|
|
599
|
+
return self
|
|
600
|
+
|
|
601
|
+
def __exit__(self, *args: Any) -> None:
|
|
602
|
+
self.close()
|
knit/cli.py
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
"""Knit CLI — manage tasks, agents, projects, and autopilots from the terminal.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
# Set auth (agent API key for task operations)
|
|
6
|
+
export KNIT_API_KEY=sk_live_xxx
|
|
7
|
+
knit task list --status open
|
|
8
|
+
|
|
9
|
+
# Set auth (JWT token for admin operations)
|
|
10
|
+
export KNIT_JWT_TOKEN=<jwt_from_login>
|
|
11
|
+
knit project list
|
|
12
|
+
knit autopilot trigger <id>
|
|
13
|
+
|
|
14
|
+
# Or use --token on individual commands
|
|
15
|
+
knit agent approve <id> --token $(knit auth login)
|
|
16
|
+
|
|
17
|
+
# Run the daemon
|
|
18
|
+
knit daemon start
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from knit.agent import KnitAgent
|
|
32
|
+
|
|
33
|
+
DEFAULT_HUB = os.getenv("KNIT_HUB_URL", "http://localhost:8000/api/v1")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Auth helpers
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_agent() -> KnitAgent:
|
|
42
|
+
"""Create a KnitAgent from env vars."""
|
|
43
|
+
api_key = os.getenv("KNIT_API_KEY")
|
|
44
|
+
if not api_key:
|
|
45
|
+
print("Error: KNIT_API_KEY environment variable is required for agent operations.", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
return KnitAgent(
|
|
48
|
+
name="knit-cli",
|
|
49
|
+
hub_url=os.getenv("KNIT_HUB_URL", DEFAULT_HUB),
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_admin_client(token: str | None = None) -> httpx.Client | None:
|
|
55
|
+
"""Create an httpx client with JWT auth."""
|
|
56
|
+
token = token or os.getenv("KNIT_JWT_TOKEN")
|
|
57
|
+
if not token:
|
|
58
|
+
return None
|
|
59
|
+
hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
|
|
60
|
+
return httpx.Client(
|
|
61
|
+
base_url=hub.rstrip("/"),
|
|
62
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _admin_request(method: str, path: str, token: str | None = None,
|
|
67
|
+
json_data: dict | None = None, params: dict | None = None) -> Any:
|
|
68
|
+
"""Make an authenticated request with a JWT token (admin operations)."""
|
|
69
|
+
client = _get_admin_client(token)
|
|
70
|
+
if not client:
|
|
71
|
+
print("Error: --token or KNIT_JWT_TOKEN required (admin operation).\n"
|
|
72
|
+
"Run `knit auth login` to get a token, then set KNIT_JWT_TOKEN.", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
try:
|
|
75
|
+
resp = client.request(method, path, json=json_data, params=params)
|
|
76
|
+
if not resp.is_success:
|
|
77
|
+
detail = {}
|
|
78
|
+
try:
|
|
79
|
+
detail = resp.json().get("detail", {}) or {}
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
msg = detail.get("message", resp.text) if isinstance(detail, dict) else str(detail)
|
|
83
|
+
raise Exception(f"[{resp.status_code}] {msg}")
|
|
84
|
+
body = resp.json()
|
|
85
|
+
if "data" in body:
|
|
86
|
+
return body["data"]
|
|
87
|
+
return body
|
|
88
|
+
finally:
|
|
89
|
+
client.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Output helpers
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _output(data: Any, raw: bool = False) -> None:
|
|
98
|
+
"""Print output — JSON for scripts, pretty for humans."""
|
|
99
|
+
if raw:
|
|
100
|
+
print(json.dumps(data, indent=2, default=str))
|
|
101
|
+
elif isinstance(data, list):
|
|
102
|
+
for item in data:
|
|
103
|
+
_print_item(item)
|
|
104
|
+
elif isinstance(data, dict):
|
|
105
|
+
_print_item(data)
|
|
106
|
+
else:
|
|
107
|
+
print(data)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _print_item(item: dict) -> None:
|
|
111
|
+
"""Print a single item in human-readable format."""
|
|
112
|
+
name = item.get("title") or item.get("name") or item.get("id", "?")
|
|
113
|
+
project = item.get("project_id", "")
|
|
114
|
+
project_str = f" [{project[:8]}]" if project else ""
|
|
115
|
+
status = item.get("status") or item.get("registration_status", "")
|
|
116
|
+
priority = item.get("priority", "")
|
|
117
|
+
tags = []
|
|
118
|
+
if priority:
|
|
119
|
+
tags.append(priority.upper())
|
|
120
|
+
if status:
|
|
121
|
+
tags.append(status)
|
|
122
|
+
tag_str = f" [{', '.join(tags)}]" if tags else ""
|
|
123
|
+
print(f" {item.get('id', '?'):>12} {name}{project_str}{tag_str}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Subcommands
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_auth(args: argparse.Namespace) -> None:
|
|
132
|
+
"""Handle ``knit auth <subcommand>``."""
|
|
133
|
+
if args.sub == "login":
|
|
134
|
+
hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
|
|
135
|
+
with httpx.Client(base_url=hub.rstrip("/")) as client:
|
|
136
|
+
resp = client.post("/auth/login", json={
|
|
137
|
+
"email": args.email,
|
|
138
|
+
"password": args.password,
|
|
139
|
+
})
|
|
140
|
+
if resp.is_success:
|
|
141
|
+
data = resp.json().get("data", {})
|
|
142
|
+
print(data.get("access_token", ""))
|
|
143
|
+
else:
|
|
144
|
+
detail = resp.json().get("detail", {})
|
|
145
|
+
msg = detail.get("message", resp.text) if isinstance(detail, dict) else str(detail)
|
|
146
|
+
print(f"Login failed: {msg}", file=sys.stderr)
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_task(args: argparse.Namespace) -> None:
|
|
151
|
+
"""Handle ``knit task <subcommand>``."""
|
|
152
|
+
agent = _get_agent()
|
|
153
|
+
|
|
154
|
+
if args.sub == "list":
|
|
155
|
+
tasks = agent.list_tasks(status=args.status)
|
|
156
|
+
print(f"Tasks ({len(tasks)}):")
|
|
157
|
+
if tasks:
|
|
158
|
+
_output(tasks, raw=args.raw)
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
if args.sub == "get":
|
|
162
|
+
task = agent.get_task(args.task_id)
|
|
163
|
+
_output(task, raw=args.raw)
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
if args.sub == "create":
|
|
167
|
+
body = {
|
|
168
|
+
"project_id": args.project_id,
|
|
169
|
+
"title": args.title,
|
|
170
|
+
"description": args.description or "",
|
|
171
|
+
"priority": args.priority or "p2",
|
|
172
|
+
}
|
|
173
|
+
if args.assign_to:
|
|
174
|
+
body["assigned_to_agent_id"] = args.assign_to
|
|
175
|
+
resp = agent._post("/tasks", json=body)
|
|
176
|
+
print(f"Created: {resp['id']} — {resp['title']}")
|
|
177
|
+
_output(resp, raw=args.raw)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
if args.sub == "accept":
|
|
181
|
+
task = agent.accept_task(args.task_id)
|
|
182
|
+
print(f"Accepted: {task['id']} ({task['status']})")
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
if args.sub == "progress":
|
|
186
|
+
resp = agent.send_progress(args.task_id, args.message, args.pct)
|
|
187
|
+
print(f"Progress: {args.pct}% — {args.message}")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
if args.sub == "result":
|
|
191
|
+
resp = agent.submit_result(
|
|
192
|
+
task_id=args.task_id,
|
|
193
|
+
summary=args.summary,
|
|
194
|
+
status=args.status or "completed",
|
|
195
|
+
logs=args.logs or "",
|
|
196
|
+
metrics={"cli": True},
|
|
197
|
+
)
|
|
198
|
+
print(f"Result submitted: {resp.get('status', 'done')}")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cmd_agent(args: argparse.Namespace) -> None:
|
|
203
|
+
"""Handle ``knit agent <subcommand>``."""
|
|
204
|
+
# agent = _get_agent() # moved inside subcommands
|
|
205
|
+
|
|
206
|
+
if args.sub == "me":
|
|
207
|
+
agent = _get_agent()
|
|
208
|
+
profile = agent._get("/agents/me")
|
|
209
|
+
_output(profile, raw=args.raw)
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
if args.sub == "register":
|
|
213
|
+
hub = os.getenv("KNIT_HUB_URL", DEFAULT_HUB)
|
|
214
|
+
body = {"name": args.name, "description": args.description or ""}
|
|
215
|
+
if args.capabilities:
|
|
216
|
+
body["capabilities"] = [{"type": c} for c in args.capabilities]
|
|
217
|
+
import httpx as _httpx
|
|
218
|
+
with _httpx.Client(base_url=hub.rstrip("/")) as _client:
|
|
219
|
+
_resp = _client.post("/agents/register", json=body)
|
|
220
|
+
if not _resp.is_success:
|
|
221
|
+
detail = _resp.json().get("detail", {}) or {}
|
|
222
|
+
msg = detail.get("message", _resp.text) if isinstance(detail, dict) else str(detail)
|
|
223
|
+
print(f"Registration failed: {msg}", file=sys.stderr)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
_body = _resp.json()
|
|
226
|
+
if "data" in _body:
|
|
227
|
+
_body = _body["data"]
|
|
228
|
+
print(f"Registered: {_body['agent_id']}")
|
|
229
|
+
print(f"API Key (save this — shown once): {_body['api_key']}")
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if args.sub == "heartbeat":
|
|
233
|
+
agent = _get_agent()
|
|
234
|
+
resp = agent.heartbeat(status=args.status or "online", message=args.message)
|
|
235
|
+
d = resp if isinstance(resp, dict) else {}
|
|
236
|
+
print(f"Heartbeat: {d.get('status', 'ok')}")
|
|
237
|
+
if "pending_tasks" in d:
|
|
238
|
+
print(f" Pending tasks: {d['pending_tasks']}")
|
|
239
|
+
if "active_tasks" in d:
|
|
240
|
+
print(f" Active tasks: {d['active_tasks']}")
|
|
241
|
+
if "recent_events" in d and d["recent_events"]:
|
|
242
|
+
print(f" Recent events: {len(d['recent_events'])}")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
if args.sub == "approve":
|
|
246
|
+
token = args.token or os.getenv("KNIT_JWT_TOKEN")
|
|
247
|
+
if not token:
|
|
248
|
+
print("Error: --token or KNIT_JWT_TOKEN required (admin operation).\n"
|
|
249
|
+
"Run: export KNIT_JWT_TOKEN=$(knit auth login)", file=sys.stderr)
|
|
250
|
+
sys.exit(1)
|
|
251
|
+
resp = _admin_request("POST", f"/agents/{args.agent_id}/approve", token)
|
|
252
|
+
print(f"Approved agent: {args.agent_id}")
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def cmd_project(args: argparse.Namespace) -> None:
|
|
257
|
+
"""Handle ``knit project <subcommand>``."""
|
|
258
|
+
|
|
259
|
+
if args.sub == "list":
|
|
260
|
+
client = _get_admin_client(args.token)
|
|
261
|
+
if client:
|
|
262
|
+
try:
|
|
263
|
+
projects = _admin_request("GET", "/projects", args.token)
|
|
264
|
+
finally:
|
|
265
|
+
client.close()
|
|
266
|
+
else:
|
|
267
|
+
agent = _get_agent()
|
|
268
|
+
projects = agent._get("/projects")
|
|
269
|
+
if isinstance(projects, list):
|
|
270
|
+
pass # OK, agent API key worked for this endpoint
|
|
271
|
+
else:
|
|
272
|
+
print("Warning: Some project data may not be visible with agent API key.\n"
|
|
273
|
+
"Set KNIT_JWT_TOKEN for full access.", file=sys.stderr)
|
|
274
|
+
items = projects if isinstance(projects, list) else projects.get("data", [])
|
|
275
|
+
print(f"Projects ({len(items)}):")
|
|
276
|
+
_output(items, raw=args.raw)
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
if args.sub == "create":
|
|
280
|
+
body = {"name": args.name}
|
|
281
|
+
if args.description:
|
|
282
|
+
body["description"] = args.description
|
|
283
|
+
resp = _admin_request("POST", "/projects", args.token, json_data=body)
|
|
284
|
+
print(f'Created project: {resp.get("id", "?")} -- {args.name}')
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
if args.sub == "resources":
|
|
288
|
+
return
|
|
289
|
+
|
|
290
|
+
if args.sub == "resources":
|
|
291
|
+
pid = args.project_id
|
|
292
|
+
if args.sub_sub == "list":
|
|
293
|
+
resources = _admin_request("GET", f"/projects/{pid}/resources", args.token)
|
|
294
|
+
items = resources if isinstance(resources, list) else resources.get("data", [])
|
|
295
|
+
print(f"Resources ({len(items)}):")
|
|
296
|
+
for r in items:
|
|
297
|
+
print(f" {r.get('id','?'):>12} {r.get('resource_type','?'):12} {r.get('name','')}")
|
|
298
|
+
return
|
|
299
|
+
if args.sub_sub == "add":
|
|
300
|
+
body = {
|
|
301
|
+
"resource_type": args.resource_type,
|
|
302
|
+
"name": args.name,
|
|
303
|
+
"value": args.value,
|
|
304
|
+
}
|
|
305
|
+
if args.display_value:
|
|
306
|
+
body["display_value"] = args.display_value
|
|
307
|
+
resp = _admin_request("POST", f"/projects/{pid}/resources", args.token, json_data=body)
|
|
308
|
+
print(f"Added resource: {resp.get('id','?')[:8]}... — {args.name}")
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
if args.sub == "members":
|
|
312
|
+
pid = args.project_id
|
|
313
|
+
if args.sub_sub == "list":
|
|
314
|
+
members = _admin_request("GET", f"/projects/{pid}/members", args.token)
|
|
315
|
+
items = members if isinstance(members, list) else members.get("data", [])
|
|
316
|
+
print(f"Members ({len(items)}):")
|
|
317
|
+
for m in items:
|
|
318
|
+
name = m.get("name", m.get("user_id") or m.get("agent_id") or "?")
|
|
319
|
+
mtype = m.get("member_type", "?")
|
|
320
|
+
role = m.get("role", "?")
|
|
321
|
+
print(f" {name:20} {mtype:6} [{role}]")
|
|
322
|
+
return
|
|
323
|
+
if args.sub_sub == "add":
|
|
324
|
+
body: dict[str, Any] = {"role": args.role}
|
|
325
|
+
if args.agent_id:
|
|
326
|
+
body["member_type"] = "agent"
|
|
327
|
+
body["agent_id"] = args.agent_id
|
|
328
|
+
elif args.user_id:
|
|
329
|
+
body["member_type"] = "human"
|
|
330
|
+
body["user_id"] = args.user_id
|
|
331
|
+
else:
|
|
332
|
+
print("Error: specify --agent-id or --user-id", file=sys.stderr)
|
|
333
|
+
sys.exit(1)
|
|
334
|
+
resp = _admin_request("POST", f"/projects/{pid}/members", args.token, json_data=body)
|
|
335
|
+
print(f"Added member to project {pid[:8]}...")
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def cmd_autopilot(args: argparse.Namespace) -> None:
|
|
340
|
+
"""Handle ``knit autopilot <subcommand>``."""
|
|
341
|
+
|
|
342
|
+
if args.sub == "list":
|
|
343
|
+
params: dict[str, str] = {}
|
|
344
|
+
if args.project_id:
|
|
345
|
+
params["project_id"] = args.project_id
|
|
346
|
+
resp = _admin_request("GET", "/autopilots", args.token, params=params)
|
|
347
|
+
items = resp if isinstance(resp, list) else resp.get("data", [])
|
|
348
|
+
print(f"Autopilots ({len(items)}):")
|
|
349
|
+
for a in items:
|
|
350
|
+
status = "active" if a.get("is_active") else "paused"
|
|
351
|
+
print(f" {a.get('id','?'):>12} {a.get('name','')} [{status}]")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
if args.sub == "create":
|
|
355
|
+
body = {"project_id": args.project_id, "name": args.name}
|
|
356
|
+
if args.cron:
|
|
357
|
+
body["cron_expression"] = args.cron
|
|
358
|
+
if args.assign_to:
|
|
359
|
+
body["assigned_to_agent_id"] = args.assign_to
|
|
360
|
+
if args.title_template:
|
|
361
|
+
body["title_template"] = args.title_template
|
|
362
|
+
if args.priority:
|
|
363
|
+
body["priority"] = args.priority
|
|
364
|
+
resp = _admin_request("POST", "/autopilots", args.token, json_data=body)
|
|
365
|
+
d = resp if isinstance(resp, dict) else {}
|
|
366
|
+
print(f'Created autopilot: {d.get("id", "?")} -- {args.name}')
|
|
367
|
+
return
|
|
368
|
+
|
|
369
|
+
if args.sub == "trigger":
|
|
370
|
+
resp = _admin_request("POST", f"/autopilots/{args.autopilot_id}/trigger", args.token)
|
|
371
|
+
d = resp if isinstance(resp, dict) else {}
|
|
372
|
+
print(f"Triggered: task {d.get('task_id','?')[:8]}... created")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
if args.sub == "runs":
|
|
376
|
+
resp = _admin_request("GET", f"/autopilots/{args.autopilot_id}/runs", args.token)
|
|
377
|
+
items = resp if isinstance(resp, list) else resp.get("data", [])
|
|
378
|
+
print(f"Runs ({len(items)}):")
|
|
379
|
+
for r in items:
|
|
380
|
+
print(f" status={r.get('status','?')} task={r.get('task_id','?')[:8]}... at={r.get('run_at','?')[:19]}")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Root parser
|
|
386
|
+
# ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
390
|
+
parser = argparse.ArgumentParser(prog="knit", description="Knit agent CLI")
|
|
391
|
+
parser.add_argument("--raw", action="store_true", help="JSON output")
|
|
392
|
+
parser.add_argument("--token", help="JWT token (for admin operations; also reads KNIT_JWT_TOKEN)")
|
|
393
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
394
|
+
|
|
395
|
+
# ── auth ──────────────────────────────────────────
|
|
396
|
+
au = sub.add_parser("auth", help="Authentication")
|
|
397
|
+
ausub = au.add_subparsers(dest="sub", required=True)
|
|
398
|
+
al = ausub.add_parser("login", help="Login and print JWT token")
|
|
399
|
+
al.add_argument("--email", default="admin@knit.dev")
|
|
400
|
+
al.add_argument("--password", default="password123")
|
|
401
|
+
|
|
402
|
+
# ── task ──────────────────────────────────────────
|
|
403
|
+
t = sub.add_parser("task", help="Manage tasks")
|
|
404
|
+
tsub = t.add_subparsers(dest="sub", required=True)
|
|
405
|
+
|
|
406
|
+
tp = tsub.add_parser("list", help="List tasks")
|
|
407
|
+
tp.add_argument("--status", help="Filter by status")
|
|
408
|
+
tp.add_argument("--project-id", help="Filter by project")
|
|
409
|
+
|
|
410
|
+
tsub.add_parser("get", help="Get task detail").add_argument("task_id")
|
|
411
|
+
|
|
412
|
+
tc = tsub.add_parser("create", help="Create a task")
|
|
413
|
+
tc.add_argument("--project-id", required=True)
|
|
414
|
+
tc.add_argument("--title", required=True)
|
|
415
|
+
tc.add_argument("--description")
|
|
416
|
+
tc.add_argument("--priority", choices=["p0", "p1", "p2", "p3"])
|
|
417
|
+
tc.add_argument("--assign-to", dest="assign_to", help="Agent ID")
|
|
418
|
+
|
|
419
|
+
tsub.add_parser("accept", help="Accept a task").add_argument("task_id")
|
|
420
|
+
|
|
421
|
+
tp2 = tsub.add_parser("progress", help="Report progress")
|
|
422
|
+
tp2.add_argument("task_id")
|
|
423
|
+
tp2.add_argument("--message", required=True)
|
|
424
|
+
tp2.add_argument("--pct", type=int, default=50)
|
|
425
|
+
|
|
426
|
+
tr = tsub.add_parser("result", help="Submit result")
|
|
427
|
+
tr.add_argument("task_id")
|
|
428
|
+
tr.add_argument("--summary", required=True)
|
|
429
|
+
tr.add_argument("--status", choices=["completed", "failed"])
|
|
430
|
+
tr.add_argument("--logs")
|
|
431
|
+
|
|
432
|
+
# ── agent ─────────────────────────────────────────
|
|
433
|
+
a = sub.add_parser("agent", help="Manage agent identity")
|
|
434
|
+
asub = a.add_subparsers(dest="sub", required=True)
|
|
435
|
+
|
|
436
|
+
asub.add_parser("me", help="Show profile")
|
|
437
|
+
|
|
438
|
+
ar = asub.add_parser("register", help="Register this agent")
|
|
439
|
+
ar.add_argument("--name", required=True)
|
|
440
|
+
ar.add_argument("--description")
|
|
441
|
+
ar.add_argument("--capabilities", nargs="*")
|
|
442
|
+
|
|
443
|
+
ah = asub.add_parser("heartbeat", help="Send heartbeat")
|
|
444
|
+
ah.add_argument("--status", choices=["online", "busy", "offline"])
|
|
445
|
+
ah.add_argument("--message")
|
|
446
|
+
|
|
447
|
+
aapp = asub.add_parser("approve", help="Approve a pending agent (admin)")
|
|
448
|
+
aapp.add_argument("agent_id")
|
|
449
|
+
|
|
450
|
+
# ── project ───────────────────────────────────────
|
|
451
|
+
p = sub.add_parser("project", help="Manage projects")
|
|
452
|
+
psub = p.add_subparsers(dest="sub", required=True)
|
|
453
|
+
|
|
454
|
+
psub.add_parser("list", help="List projects")
|
|
455
|
+
|
|
456
|
+
pc = psub.add_parser("create", help="Create a project")
|
|
457
|
+
pc.add_argument("--name", required=True)
|
|
458
|
+
pc.add_argument("--description")
|
|
459
|
+
|
|
460
|
+
pr = psub.add_parser("resources", help="Manage project resources")
|
|
461
|
+
pr.add_argument("project_id")
|
|
462
|
+
prsub = pr.add_subparsers(dest="sub_sub", required=True)
|
|
463
|
+
prsub.add_parser("list", help="List resources")
|
|
464
|
+
pra = prsub.add_parser("add", help="Add a resource")
|
|
465
|
+
pra.add_argument("--type", dest="resource_type", required=True,
|
|
466
|
+
choices=["github_repo", "url", "document", "env_var"])
|
|
467
|
+
pra.add_argument("--name", required=True)
|
|
468
|
+
pra.add_argument("--value", required=True)
|
|
469
|
+
pra.add_argument("--display-value", dest="display_value")
|
|
470
|
+
|
|
471
|
+
pm = psub.add_parser("members", help="Manage project members")
|
|
472
|
+
pm.add_argument("project_id")
|
|
473
|
+
pmsub = pm.add_subparsers(dest="sub_sub", required=True)
|
|
474
|
+
pml = pmsub.add_parser("list", help="List members")
|
|
475
|
+
pma = pmsub.add_parser("add", help="Add a member")
|
|
476
|
+
pma.add_argument("--agent-id", help="Agent ID to add")
|
|
477
|
+
pma.add_argument("--user-id", help="User ID to add")
|
|
478
|
+
pma.add_argument("--role", default="member", choices=["admin", "member", "viewer"])
|
|
479
|
+
|
|
480
|
+
# ── autopilot ─────────────────────────────────────
|
|
481
|
+
ap = sub.add_parser("autopilot", help="Manage autopilots")
|
|
482
|
+
apsub = ap.add_subparsers(dest="sub", required=True)
|
|
483
|
+
|
|
484
|
+
apl = apsub.add_parser("list", help="List autopilots")
|
|
485
|
+
apl.add_argument("--project-id")
|
|
486
|
+
|
|
487
|
+
apc = apsub.add_parser("create", help="Create an autopilot")
|
|
488
|
+
apc.add_argument("--project-id", required=True)
|
|
489
|
+
apc.add_argument("--name", required=True)
|
|
490
|
+
apc.add_argument("--cron")
|
|
491
|
+
apc.add_argument("--assign-to", dest="assign_to")
|
|
492
|
+
apc.add_argument("--title-template", dest="title_template")
|
|
493
|
+
apc.add_argument("--priority", choices=["p0", "p1", "p2", "p3"])
|
|
494
|
+
|
|
495
|
+
apsub.add_parser("trigger", help="Trigger an autopilot").add_argument("autopilot_id")
|
|
496
|
+
apsub.add_parser("runs", help="Show run history").add_argument("autopilot_id")
|
|
497
|
+
|
|
498
|
+
# ── daemon ────────────────────────────────────────
|
|
499
|
+
d = sub.add_parser("daemon", help="Run the agent daemon")
|
|
500
|
+
dsub = d.add_subparsers(dest="sub", required=True)
|
|
501
|
+
ds = dsub.add_parser("start", help="Start the daemon")
|
|
502
|
+
ds.add_argument("--hub", default=os.getenv("KNIT_HUB_URL", DEFAULT_HUB))
|
|
503
|
+
ds.add_argument("--api-key", default=os.getenv("KNIT_API_KEY", ""))
|
|
504
|
+
ds.add_argument("--name", default="knit-daemon")
|
|
505
|
+
ds.add_argument("--poll-interval", type=int, default=10)
|
|
506
|
+
|
|
507
|
+
return parser
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def main() -> None:
|
|
511
|
+
"""CLI entry point — dispatches to the right subcommand handler."""
|
|
512
|
+
parser = _build_parser()
|
|
513
|
+
args = parser.parse_args()
|
|
514
|
+
|
|
515
|
+
if args.command == "daemon":
|
|
516
|
+
from knit.daemon import _daemon_main
|
|
517
|
+
_daemon_main(args)
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
handlers = {
|
|
521
|
+
"auth": cmd_auth,
|
|
522
|
+
"task": cmd_task,
|
|
523
|
+
"agent": cmd_agent,
|
|
524
|
+
"project": cmd_project,
|
|
525
|
+
"autopilot": cmd_autopilot,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
handler = handlers.get(args.command)
|
|
529
|
+
if handler:
|
|
530
|
+
try:
|
|
531
|
+
handler(args)
|
|
532
|
+
except Exception as e:
|
|
533
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
534
|
+
sys.exit(1)
|
|
535
|
+
else:
|
|
536
|
+
parser.print_help()
|
|
537
|
+
sys.exit(1)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
if __name__ == "__main__":
|
|
541
|
+
main()
|
knit/daemon.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Agent daemon — autonomous Knit agent that auto-discovers and executes tasks.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
knit daemon start --hub http://localhost:8000 --api-key sk_live_xxx
|
|
6
|
+
|
|
7
|
+
The daemon heartbeats, polls for tasks, and executes them via the best
|
|
8
|
+
available CLI agent on PATH (claude, codex, openclaw, etc.).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from knit.agent import KnitAgent, TaskContext
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("knit-daemon")
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# CLI auto-detection
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
_CLI_CANDIDATES = ["claude", "codex", "copilot", "openclaw", "opencode", "hermes", "gemini", "pi"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_clis() -> list[str]:
|
|
32
|
+
"""Return CLI agents found on PATH."""
|
|
33
|
+
return [c for c in _CLI_CANDIDATES if shutil.which(c)]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# KnitDaemon
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KnitDaemon:
|
|
42
|
+
"""A background agent that auto-discovers, executes, and reports on tasks.
|
|
43
|
+
|
|
44
|
+
Wraps ``KnitAgent`` from the SDK and adds automatic CLI detection,
|
|
45
|
+
subprocess execution, and SSE-based event listening.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
hub_url: str,
|
|
51
|
+
api_key: str,
|
|
52
|
+
name: str = "knit-daemon",
|
|
53
|
+
description: str = "Daemon agent that auto-executes assigned tasks",
|
|
54
|
+
):
|
|
55
|
+
self.agent = KnitAgent(
|
|
56
|
+
name=name,
|
|
57
|
+
hub_url=hub_url.rstrip("/"),
|
|
58
|
+
api_key=api_key,
|
|
59
|
+
description=description,
|
|
60
|
+
)
|
|
61
|
+
self.available_clis: list[str] = []
|
|
62
|
+
self._name = name
|
|
63
|
+
|
|
64
|
+
def detect(self) -> list[str]:
|
|
65
|
+
"""Auto-detect available agent CLIs on PATH."""
|
|
66
|
+
self.available_clis = detect_clis()
|
|
67
|
+
return self.available_clis
|
|
68
|
+
|
|
69
|
+
def execute(self, task: dict, ctx: TaskContext) -> dict[str, Any]:
|
|
70
|
+
"""Execute a task by spawning the best available CLI agent.
|
|
71
|
+
|
|
72
|
+
The task context (title + description) is fed as a prompt.
|
|
73
|
+
Stdout/stderr are captured and returned as the task result.
|
|
74
|
+
"""
|
|
75
|
+
if not self.available_clis:
|
|
76
|
+
self.detect()
|
|
77
|
+
if not self.available_clis:
|
|
78
|
+
logger.warning("No agent CLI found on PATH")
|
|
79
|
+
return {"summary": "No agent CLI available (tried: claude, codex, etc.)", "status": "failed"}
|
|
80
|
+
|
|
81
|
+
cli = self.available_clis[0]
|
|
82
|
+
prompt = self._build_prompt(task)
|
|
83
|
+
logger.info("Executing task %s via %s", task.get("id", "?"), cli)
|
|
84
|
+
|
|
85
|
+
ctx.progress(f"Starting execution via {cli}...", 5)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
result = subprocess.run(
|
|
89
|
+
[cli, "-p", prompt],
|
|
90
|
+
capture_output=True, text=True, timeout=600,
|
|
91
|
+
)
|
|
92
|
+
except subprocess.TimeoutExpired:
|
|
93
|
+
logger.warning("Task %s timed out after 600s", task.get("id", "?"))
|
|
94
|
+
return {"summary": "Task timed out after 600 seconds", "status": "failed", "logs": "Timeout"}
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
logger.error("CLI %s not found despite detection", cli)
|
|
97
|
+
return {"summary": f"CLI {cli} not found", "status": "failed"}
|
|
98
|
+
|
|
99
|
+
ctx.progress("Execution complete, submitting result...", 95)
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
"summary": result.stdout[:2000] or "(no stdout)",
|
|
103
|
+
"status": "completed" if result.returncode == 0 else "failed",
|
|
104
|
+
"logs": (result.stdout + "\n--- stderr ---\n" + result.stderr)[:5000],
|
|
105
|
+
"metrics": {
|
|
106
|
+
"return_code": result.returncode,
|
|
107
|
+
"output_bytes": len(result.stdout),
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
def _build_prompt(self, task: dict) -> str:
|
|
112
|
+
"""Build a task prompt from the task description."""
|
|
113
|
+
parts = [f"Task: {task['title']}"]
|
|
114
|
+
if task.get("description"):
|
|
115
|
+
parts.append("")
|
|
116
|
+
parts.append(task["description"])
|
|
117
|
+
parts.append("")
|
|
118
|
+
parts.append("Complete this task. Report your findings and any code changes.")
|
|
119
|
+
return "\n".join(parts)
|
|
120
|
+
|
|
121
|
+
def serve(self, poll_interval: int = 10) -> None:
|
|
122
|
+
"""Run the daemon: detect CLIs, heartbeat, poll, execute."""
|
|
123
|
+
found = self.detect()
|
|
124
|
+
print(f"Knit daemon starting. Detected CLIs: {found or '(none)'}")
|
|
125
|
+
if not found:
|
|
126
|
+
print("Install one of: claude, codex, openclaw, opencode")
|
|
127
|
+
print(f"Connected to hub at {self.agent.base_url}")
|
|
128
|
+
print("Press Ctrl+C to stop\n")
|
|
129
|
+
|
|
130
|
+
self.agent.start_heartbeat_loop(message=f"Daemon online, CLIs: {found}")
|
|
131
|
+
self.agent.serve(
|
|
132
|
+
handle_task=lambda t, ctx: self.execute(t, ctx),
|
|
133
|
+
poll_interval=poll_interval,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# CLI entry point
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> None:
|
|
143
|
+
"""CLI entry point (backward compatible — calls _daemon_main)."""
|
|
144
|
+
import argparse
|
|
145
|
+
parser = argparse.ArgumentParser(description="Knit Agent Daemon")
|
|
146
|
+
parser.add_argument("--hub", default=os.getenv("KNIT_HUB_URL", "http://localhost:8000/api/v1"),
|
|
147
|
+
help="Knit hub base URL")
|
|
148
|
+
parser.add_argument("--api-key", required=True,
|
|
149
|
+
help="Agent API key (sk_live_...)")
|
|
150
|
+
parser.add_argument("--name", default="knit-daemon",
|
|
151
|
+
help="Agent name displayed in the hub")
|
|
152
|
+
parser.add_argument("--poll-interval", type=int, default=10,
|
|
153
|
+
help="Seconds between task polls")
|
|
154
|
+
|
|
155
|
+
args = parser.parse_args()
|
|
156
|
+
_daemon_main(args)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _daemon_main(args: argparse.Namespace) -> None:
|
|
160
|
+
"""Run the daemon from parsed CLI args."""
|
|
161
|
+
logging.basicConfig(
|
|
162
|
+
level=logging.INFO,
|
|
163
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
daemon = KnitDaemon(
|
|
167
|
+
hub_url=args.hub,
|
|
168
|
+
api_key=args.api_key,
|
|
169
|
+
name=args.name,
|
|
170
|
+
)
|
|
171
|
+
daemon.serve(poll_interval=args.poll_interval)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: knit-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for building agents on the Knit agent hub
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx>=0.25.0
|
|
7
|
+
Requires-Dist: pydantic>=2.0.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
10
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
knit/__init__.py,sha256=LbEzJbv8yAwYE8JuFsMYRfjn1lKfbI2Y7mNrLz2w9qM,204
|
|
2
|
+
knit/__main__.py,sha256=kmd6JzWDphjDgtQfRop4z5aEBxMkAaIDn7B3626geEA,253
|
|
3
|
+
knit/agent.py,sha256=iUbNKTigKS_oDCOo1qoqztUQcwOa0wDY-fR46OFGMmI,21103
|
|
4
|
+
knit/cli.py,sha256=aiXMEWEJiwEbQY8xecrfyqeD3o5qQ33gae0gmhl4kAg,20987
|
|
5
|
+
knit/daemon.py,sha256=zBEsDD721dLzYfRi9fdNGub_NTC06u2hCYfXedzOQ54,6144
|
|
6
|
+
knit_sdk-0.1.0.dist-info/METADATA,sha256=-2l1Sf1cwYs_k0Excr-mKrhHvztR--rfNOzW26nxchg,313
|
|
7
|
+
knit_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
knit_sdk-0.1.0.dist-info/entry_points.txt,sha256=geJwCYNp6Fy9Z0lJGNxqAkShqbHt0fUGAYtZN5Kn0Wk,39
|
|
9
|
+
knit_sdk-0.1.0.dist-info/top_level.txt,sha256=B4a3-UpmAKcaTdMJf1bUs6ViNd5lWuZv_zY7h7J9khE,5
|
|
10
|
+
knit_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
knit
|