nightshift-sdk 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.
@@ -0,0 +1,52 @@
1
+ """CLI config — reads/writes ~/.nightshift/config.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import tomllib
8
+
9
+ import tomli_w
10
+
11
+
12
+ CONFIG_DIR = os.path.expanduser("~/.nightshift")
13
+ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.toml")
14
+
15
+
16
+ def load_config() -> dict:
17
+ """Load the CLI config file, returning {} if it doesn't exist."""
18
+ if not os.path.exists(CONFIG_PATH):
19
+ return {}
20
+ with open(CONFIG_PATH, "rb") as f:
21
+ return tomllib.load(f)
22
+
23
+
24
+ def save_config(data: dict) -> None:
25
+ """Write the CLI config file with restricted permissions (0600)."""
26
+ os.makedirs(CONFIG_DIR, exist_ok=True)
27
+ with open(CONFIG_PATH, "wb") as f:
28
+ tomli_w.dump(data, f)
29
+ os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR)
30
+
31
+
32
+ def get_url() -> str:
33
+ """Get the platform URL from config."""
34
+ cfg = load_config()
35
+ url = cfg.get("url", "")
36
+ if not url:
37
+ raise SystemExit("Not logged in. Run: nightshift login --url <URL> --api-key <KEY>")
38
+ return url
39
+
40
+
41
+ def get_api_key() -> str:
42
+ """Get the API key from config."""
43
+ cfg = load_config()
44
+ key = cfg.get("api_key", "")
45
+ if not key:
46
+ raise SystemExit("Not logged in. Run: nightshift login --url <URL> --api-key <KEY>")
47
+ return key
48
+
49
+
50
+ def get_auth_headers() -> dict[str, str]:
51
+ """Get Authorization headers for API requests."""
52
+ return {"Authorization": f"Bearer {get_api_key()}"}
nightshift/cli/main.py ADDED
@@ -0,0 +1,39 @@
1
+ """Nightshift CLI — deploy and manage agents on the platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from nightshift.cli.commands.agents import agents
8
+ from nightshift.cli.commands.api_key import api_key
9
+ from nightshift.cli.commands.deploy import deploy
10
+ from nightshift.cli.commands.login import login
11
+ from nightshift.cli.commands.logs import logs
12
+ from nightshift.cli.commands.run import run
13
+
14
+
15
+ @click.group()
16
+ def cli() -> None:
17
+ """Nightshift — autonomous agent orchestrator with Firecracker VMs."""
18
+ pass
19
+
20
+
21
+ # Register subcommands
22
+ cli.add_command(login)
23
+ cli.add_command(deploy)
24
+ cli.add_command(run)
25
+ cli.add_command(logs)
26
+ cli.add_command(agents)
27
+ cli.add_command(api_key)
28
+
29
+
30
+ @cli.command()
31
+ @click.option("--host", default="0.0.0.0", help="Bind host")
32
+ @click.option("--port", default=3000, type=int, help="Bind port")
33
+ def serve(host: str, port: int) -> None:
34
+ """Start the Nightshift platform server."""
35
+ import asyncio
36
+
37
+ from nightshift.server import start_server
38
+
39
+ asyncio.run(start_server(host=host, port=port))
nightshift/config.py ADDED
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class NightshiftConfig:
9
+ workspace: str = ""
10
+ port: int = 3000
11
+ kernel_path: str = "/opt/nightshift/vmlinux"
12
+ base_rootfs_path: str = "/opt/nightshift/rootfs.ext4"
13
+ vm_timeout_seconds: int = 1800 # 30 minutes
14
+ vm_health_timeout_seconds: int = 60
15
+ vm_event_port: int = 8080
16
+ db_path: str = "/opt/nightshift/nightshift.db"
17
+ agents_storage_dir: str = "/opt/nightshift/agents"
18
+
19
+ @staticmethod
20
+ def from_env() -> NightshiftConfig:
21
+ return NightshiftConfig(
22
+ workspace=os.environ.get("NIGHTSHIFT_WORKSPACE", os.getcwd()),
23
+ port=int(os.environ.get("NIGHTSHIFT_PORT", "3000")),
24
+ kernel_path=os.environ.get("NIGHTSHIFT_KERNEL_PATH", "/opt/nightshift/vmlinux"),
25
+ base_rootfs_path=os.environ.get(
26
+ "NIGHTSHIFT_ROOTFS_PATH", "/opt/nightshift/rootfs.ext4"
27
+ ),
28
+ db_path=os.environ.get("NIGHTSHIFT_DB_PATH", "/opt/nightshift/nightshift.db"),
29
+ agents_storage_dir=os.environ.get(
30
+ "NIGHTSHIFT_AGENTS_DIR", "/opt/nightshift/agents"
31
+ ),
32
+ )
33
+
34
+ def env_vars_for_vm(self) -> dict[str, str]:
35
+ """Collect platform env vars to pass into the VM."""
36
+ env: dict[str, str] = {}
37
+ env["NIGHTSHIFT_WORKSPACE"] = "/workspace"
38
+ return env
nightshift/events.py ADDED
@@ -0,0 +1,124 @@
1
+ """Event types and in-memory event buffer.
2
+
3
+ EventBuffer replaces the previous EventLog/EventStore/EventSink stack
4
+ with a single class that stores events per run_id and supports async streaming.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import time
13
+ from dataclasses import asdict, dataclass, field
14
+ from typing import AsyncIterator, Literal, Union
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclass
20
+ class BaseEvent:
21
+ timestamp: float = field(default_factory=time.time)
22
+ run_id: str | None = None
23
+
24
+
25
+ @dataclass
26
+ class StartedEvent(BaseEvent):
27
+ type: Literal["nightshift.started"] = "nightshift.started"
28
+ workspace: str = ""
29
+
30
+
31
+ @dataclass
32
+ class CompletedEvent(BaseEvent):
33
+ type: Literal["nightshift.completed"] = "nightshift.completed"
34
+
35
+
36
+ @dataclass
37
+ class ErrorEvent(BaseEvent):
38
+ type: Literal["nightshift.error"] = "nightshift.error"
39
+ error: str = ""
40
+
41
+
42
+ @dataclass
43
+ class InterruptedEvent(BaseEvent):
44
+ type: Literal["nightshift.interrupted"] = "nightshift.interrupted"
45
+ reason: Literal["user_quit", "user_stop"] = "user_quit"
46
+
47
+
48
+ NightshiftEvent = Union[
49
+ StartedEvent,
50
+ CompletedEvent,
51
+ ErrorEvent,
52
+ InterruptedEvent,
53
+ ]
54
+
55
+ TERMINAL_EVENTS = {"nightshift.completed", "nightshift.error", "nightshift.interrupted"}
56
+
57
+
58
+ class EventBuffer:
59
+ """Simple in-memory event buffer with async streaming.
60
+
61
+ Stores events per run_id and allows consumers to stream them
62
+ (replay + live-tail) via an asyncio.Condition.
63
+ """
64
+
65
+ def __init__(self) -> None:
66
+ self._runs: dict[str, list[tuple[str, dict]]] = {}
67
+ self._cond: asyncio.Condition = asyncio.Condition()
68
+ self._done: set[str] = set()
69
+
70
+ async def append(self, run_id: str, event_type: str, payload: dict) -> None:
71
+ """Append an event to a run's buffer and notify waiters."""
72
+ self._runs.setdefault(run_id, []).append((event_type, payload))
73
+ async with self._cond:
74
+ self._cond.notify_all()
75
+
76
+ async def stream(self, run_id: str, cursor: int = 0) -> AsyncIterator[tuple[str, dict]]:
77
+ """Yield events for a run, replaying from cursor then live-tailing.
78
+
79
+ Terminates when the run is marked done via cleanup().
80
+ """
81
+ while True:
82
+ events = self._runs.get(run_id, [])
83
+ while cursor < len(events):
84
+ yield events[cursor]
85
+ cursor += 1
86
+ if run_id in self._done:
87
+ return
88
+ async with self._cond:
89
+ await self._cond.wait()
90
+
91
+ async def cleanup(self, run_id: str) -> None:
92
+ """Mark a run as done and remove its events."""
93
+ self._runs.pop(run_id, None)
94
+ self._done.add(run_id)
95
+ async with self._cond:
96
+ self._cond.notify_all()
97
+
98
+ # ── Convenience methods (used by task.py, vm/manager.py, agent/entry.py) ──
99
+
100
+ async def publish(self, run_id: str, event: NightshiftEvent) -> None:
101
+ """Publish a typed NightshiftEvent."""
102
+ event.run_id = run_id
103
+ payload = asdict(event)
104
+ event_type = payload.pop("type")
105
+ await self.append(run_id, event_type, payload)
106
+
107
+ async def publish_raw(self, run_id: str, event_type: str, data: dict) -> None:
108
+ """Forward a raw event dict (e.g. from the guest SSE stream)."""
109
+ payload = {k: v for k, v in data.items() if k != "type"}
110
+ await self.append(run_id, event_type, payload)
111
+
112
+ async def stream_sse(self, run_id: str) -> AsyncIterator[dict]:
113
+ """Stream events formatted for SSE (event + data keys)."""
114
+ async for event_type, payload in self.stream(run_id):
115
+ yield {
116
+ "event": event_type,
117
+ "data": json.dumps({"type": event_type, **payload}),
118
+ }
119
+ if event_type in TERMINAL_EVENTS:
120
+ return
121
+
122
+
123
+ # Backwards-compatible alias used by existing code
124
+ EventLog = EventBuffer
@@ -0,0 +1,4 @@
1
+ from nightshift.protocol.events import serialize_message
2
+ from nightshift.protocol.packaging import package_agent
3
+
4
+ __all__ = ["serialize_message", "package_agent"]
@@ -0,0 +1,36 @@
1
+ """Serialize Claude Agent SDK messages to JSON dicts for SSE transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from dataclasses import asdict, is_dataclass
7
+ from typing import Any
8
+
9
+
10
+ def serialize_message(message: Any) -> dict:
11
+ """Convert an agent message to a JSON-serializable dict.
12
+
13
+ Handles:
14
+ - dicts (passed through with defaults)
15
+ - dataclasses (converted via asdict)
16
+ - pydantic models (converted via model_dump)
17
+ - objects with a __dict__ attribute
18
+ """
19
+ if isinstance(message, dict):
20
+ data = dict(message)
21
+ elif is_dataclass(message) and not isinstance(message, type):
22
+ data = asdict(message)
23
+ elif hasattr(message, "model_dump"):
24
+ data = message.model_dump()
25
+ elif hasattr(message, "__dict__"):
26
+ data = dict(message.__dict__)
27
+ else:
28
+ data = {"data": str(message)}
29
+
30
+ if "type" not in data:
31
+ data["type"] = type(message).__name__
32
+
33
+ if "timestamp" not in data:
34
+ data["timestamp"] = time.time()
35
+
36
+ return data
@@ -0,0 +1,71 @@
1
+ """Package agent source code for injection into a Firecracker VM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import tempfile
9
+
10
+
11
+ def _find_pyproject(start_path: str) -> str | None:
12
+ """Walk up from start_path to find the nearest pyproject.toml."""
13
+ directory = os.path.dirname(os.path.abspath(start_path))
14
+ while True:
15
+ candidate = os.path.join(directory, "pyproject.toml")
16
+ if os.path.isfile(candidate):
17
+ return candidate
18
+ parent = os.path.dirname(directory)
19
+ if parent == directory:
20
+ return None
21
+ directory = parent
22
+
23
+
24
+ def package_agent(
25
+ module_path: str,
26
+ function_name: str,
27
+ prompt: str,
28
+ ) -> str:
29
+ """Package an agent's source file and a manifest for VM injection.
30
+
31
+ Creates a temp directory containing:
32
+ - The agent's source file (copied)
33
+ - manifest.json
34
+ - pyproject.toml (auto-detected from agent's project, if found)
35
+ - uv.lock (if present alongside pyproject.toml)
36
+
37
+ Returns the path to the temp directory.
38
+ """
39
+ pkg_dir = tempfile.mkdtemp(prefix="nightshift-pkg-")
40
+
41
+ # Copy the agent source file
42
+ source_filename = os.path.basename(module_path)
43
+ shutil.copy2(module_path, os.path.join(pkg_dir, source_filename))
44
+
45
+ # Auto-detect and copy pyproject.toml + uv.lock
46
+ pyproject_path = _find_pyproject(module_path)
47
+ has_pyproject = False
48
+ if pyproject_path:
49
+ shutil.copy2(pyproject_path, os.path.join(pkg_dir, "pyproject.toml"))
50
+ has_pyproject = True
51
+ project_dir = os.path.dirname(pyproject_path)
52
+ lock_path = os.path.join(project_dir, "uv.lock")
53
+ if os.path.isfile(lock_path):
54
+ shutil.copy2(lock_path, os.path.join(pkg_dir, "uv.lock"))
55
+
56
+ # Write manifest
57
+ manifest = {
58
+ "module": os.path.splitext(source_filename)[0],
59
+ "function": function_name,
60
+ "prompt": prompt,
61
+ "has_pyproject": has_pyproject,
62
+ }
63
+ with open(os.path.join(pkg_dir, "manifest.json"), "w") as f:
64
+ json.dump(manifest, f)
65
+
66
+ return pkg_dir
67
+
68
+
69
+ def cleanup_package(pkg_dir: str) -> None:
70
+ """Remove a package directory created by package_agent."""
71
+ shutil.rmtree(pkg_dir, ignore_errors=True)
nightshift/registry.py ADDED
@@ -0,0 +1,267 @@
1
+ """SQLite-backed agent and run registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+
10
+ import aiosqlite
11
+
12
+
13
+ def _now() -> str:
14
+ return datetime.now(timezone.utc).isoformat()
15
+
16
+
17
+ def _uuid() -> str:
18
+ return str(uuid.uuid4())
19
+
20
+
21
+ @dataclass
22
+ class AgentRecord:
23
+ id: str
24
+ tenant_id: str
25
+ name: str
26
+ source_filename: str
27
+ function_name: str
28
+ config_json: str
29
+ storage_path: str
30
+ created_at: str
31
+ updated_at: str
32
+
33
+
34
+ @dataclass
35
+ class RunRecord:
36
+ id: str
37
+ agent_id: str
38
+ tenant_id: str
39
+ prompt: str
40
+ status: str
41
+ created_at: str
42
+ completed_at: str | None = None
43
+ error: str | None = None
44
+
45
+
46
+ class AgentRegistry:
47
+ """Async SQLite registry for agents, runs, and API keys."""
48
+
49
+ def __init__(self, db_path: str) -> None:
50
+ self.db_path = db_path
51
+ self._db: aiosqlite.Connection | None = None
52
+
53
+ async def init_db(self) -> None:
54
+ """Open the database and create tables if needed."""
55
+ self._db = await aiosqlite.connect(self.db_path)
56
+ self._db.row_factory = aiosqlite.Row
57
+ await self._db.executescript(
58
+ """
59
+ CREATE TABLE IF NOT EXISTS agents (
60
+ id TEXT PRIMARY KEY,
61
+ tenant_id TEXT NOT NULL,
62
+ name TEXT NOT NULL,
63
+ source_filename TEXT NOT NULL,
64
+ function_name TEXT NOT NULL,
65
+ config_json TEXT NOT NULL,
66
+ storage_path TEXT NOT NULL,
67
+ created_at TEXT NOT NULL,
68
+ updated_at TEXT NOT NULL,
69
+ UNIQUE(tenant_id, name)
70
+ );
71
+
72
+ CREATE TABLE IF NOT EXISTS runs (
73
+ id TEXT PRIMARY KEY,
74
+ agent_id TEXT NOT NULL,
75
+ tenant_id TEXT NOT NULL,
76
+ prompt TEXT NOT NULL,
77
+ status TEXT NOT NULL DEFAULT 'started',
78
+ created_at TEXT NOT NULL,
79
+ completed_at TEXT,
80
+ error TEXT
81
+ );
82
+
83
+ CREATE TABLE IF NOT EXISTS api_keys (
84
+ key_hash TEXT PRIMARY KEY,
85
+ tenant_id TEXT NOT NULL,
86
+ label TEXT DEFAULT '',
87
+ created_at TEXT NOT NULL
88
+ );
89
+ """
90
+ )
91
+ await self._db.commit()
92
+
93
+ async def close(self) -> None:
94
+ if self._db:
95
+ await self._db.close()
96
+
97
+ @property
98
+ def db(self) -> aiosqlite.Connection:
99
+ if self._db is None:
100
+ raise RuntimeError("Database not initialized — call init_db() first")
101
+ return self._db
102
+
103
+ # ── Agents ────────────────────────────────────────────────────
104
+
105
+ async def upsert_agent(
106
+ self,
107
+ tenant_id: str,
108
+ name: str,
109
+ source_filename: str,
110
+ function_name: str,
111
+ config_json: str,
112
+ storage_path: str,
113
+ agent_id: str | None = None,
114
+ ) -> AgentRecord:
115
+ """Insert or update an agent by (tenant_id, name)."""
116
+ now = _now()
117
+ if agent_id is None:
118
+ agent_id = _uuid()
119
+
120
+ # Check for existing agent with same (tenant_id, name)
121
+ row = await self.db.execute_fetchall(
122
+ "SELECT id, created_at FROM agents WHERE tenant_id = ? AND name = ?",
123
+ (tenant_id, name),
124
+ )
125
+ if row:
126
+ existing_id = row[0][0]
127
+ existing_created = row[0][1]
128
+ await self.db.execute(
129
+ """UPDATE agents
130
+ SET source_filename = ?, function_name = ?, config_json = ?,
131
+ storage_path = ?, updated_at = ?
132
+ WHERE id = ?""",
133
+ (source_filename, function_name, config_json, storage_path, now, existing_id),
134
+ )
135
+ await self.db.commit()
136
+ return AgentRecord(
137
+ id=existing_id,
138
+ tenant_id=tenant_id,
139
+ name=name,
140
+ source_filename=source_filename,
141
+ function_name=function_name,
142
+ config_json=config_json,
143
+ storage_path=storage_path,
144
+ created_at=existing_created,
145
+ updated_at=now,
146
+ )
147
+
148
+ await self.db.execute(
149
+ """INSERT INTO agents (id, tenant_id, name, source_filename, function_name,
150
+ config_json, storage_path, created_at, updated_at)
151
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
152
+ (agent_id, tenant_id, name, source_filename, function_name,
153
+ config_json, storage_path, now, now),
154
+ )
155
+ await self.db.commit()
156
+ return AgentRecord(
157
+ id=agent_id,
158
+ tenant_id=tenant_id,
159
+ name=name,
160
+ source_filename=source_filename,
161
+ function_name=function_name,
162
+ config_json=config_json,
163
+ storage_path=storage_path,
164
+ created_at=now,
165
+ updated_at=now,
166
+ )
167
+
168
+ async def get_agent(self, tenant_id: str, name: str) -> AgentRecord | None:
169
+ rows = await self.db.execute_fetchall(
170
+ "SELECT * FROM agents WHERE tenant_id = ? AND name = ?",
171
+ (tenant_id, name),
172
+ )
173
+ if not rows:
174
+ return None
175
+ r = rows[0]
176
+ return AgentRecord(
177
+ id=r[0], tenant_id=r[1], name=r[2], source_filename=r[3],
178
+ function_name=r[4], config_json=r[5], storage_path=r[6],
179
+ created_at=r[7], updated_at=r[8],
180
+ )
181
+
182
+ async def list_agents(self, tenant_id: str) -> list[AgentRecord]:
183
+ rows = await self.db.execute_fetchall(
184
+ "SELECT * FROM agents WHERE tenant_id = ? ORDER BY name",
185
+ (tenant_id,),
186
+ )
187
+ return [
188
+ AgentRecord(
189
+ id=r[0], tenant_id=r[1], name=r[2], source_filename=r[3],
190
+ function_name=r[4], config_json=r[5], storage_path=r[6],
191
+ created_at=r[7], updated_at=r[8],
192
+ )
193
+ for r in rows
194
+ ]
195
+
196
+ async def delete_agent(self, tenant_id: str, name: str) -> bool:
197
+ cursor = await self.db.execute(
198
+ "DELETE FROM agents WHERE tenant_id = ? AND name = ?",
199
+ (tenant_id, name),
200
+ )
201
+ await self.db.commit()
202
+ return cursor.rowcount > 0
203
+
204
+ # ── Runs ──────────────────────────────────────────────────────
205
+
206
+ async def create_run(
207
+ self,
208
+ agent_id: str,
209
+ tenant_id: str,
210
+ prompt: str,
211
+ ) -> RunRecord:
212
+ run_id = _uuid()
213
+ now = _now()
214
+ await self.db.execute(
215
+ """INSERT INTO runs (id, agent_id, tenant_id, prompt, status, created_at)
216
+ VALUES (?, ?, ?, ?, 'started', ?)""",
217
+ (run_id, agent_id, tenant_id, prompt, now),
218
+ )
219
+ await self.db.commit()
220
+ return RunRecord(
221
+ id=run_id,
222
+ agent_id=agent_id,
223
+ tenant_id=tenant_id,
224
+ prompt=prompt,
225
+ status="started",
226
+ created_at=now,
227
+ )
228
+
229
+ async def complete_run(self, run_id: str, error: str | None = None) -> None:
230
+ now = _now()
231
+ status = "error" if error else "completed"
232
+ await self.db.execute(
233
+ "UPDATE runs SET status = ?, completed_at = ?, error = ? WHERE id = ?",
234
+ (status, now, error, run_id),
235
+ )
236
+ await self.db.commit()
237
+
238
+ async def get_run(self, run_id: str) -> RunRecord | None:
239
+ rows = await self.db.execute_fetchall(
240
+ "SELECT * FROM runs WHERE id = ?", (run_id,),
241
+ )
242
+ if not rows:
243
+ return None
244
+ r = rows[0]
245
+ return RunRecord(
246
+ id=r[0], agent_id=r[1], tenant_id=r[2], prompt=r[3],
247
+ status=r[4], created_at=r[5], completed_at=r[6], error=r[7],
248
+ )
249
+
250
+ # ── API Keys ──────────────────────────────────────────────────
251
+
252
+ async def store_api_key(self, key_hash: str, tenant_id: str, label: str = "") -> None:
253
+ await self.db.execute(
254
+ """INSERT OR IGNORE INTO api_keys (key_hash, tenant_id, label, created_at)
255
+ VALUES (?, ?, ?, ?)""",
256
+ (key_hash, tenant_id, label, _now()),
257
+ )
258
+ await self.db.commit()
259
+
260
+ async def get_tenant_by_key_hash(self, key_hash: str) -> str | None:
261
+ rows = await self.db.execute_fetchall(
262
+ "SELECT tenant_id FROM api_keys WHERE key_hash = ?",
263
+ (key_hash,),
264
+ )
265
+ if not rows:
266
+ return None
267
+ return rows[0][0]
@@ -0,0 +1,4 @@
1
+ from nightshift.sdk.app import NightshiftApp
2
+ from nightshift.sdk.config import AgentConfig
3
+
4
+ __all__ = ["NightshiftApp", "AgentConfig"]
nightshift/sdk/app.py ADDED
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from dataclasses import dataclass, field
5
+ from collections.abc import Callable
6
+ from types import FunctionType
7
+ from typing import Any
8
+
9
+ from nightshift.sdk.config import AgentConfig
10
+
11
+
12
+ @dataclass
13
+ class RegisteredAgent:
14
+ """An agent function registered with NightshiftApp."""
15
+
16
+ name: str
17
+ fn: Callable[..., Any]
18
+ config: AgentConfig
19
+ module_path: str
20
+
21
+
22
+ class NightshiftApp:
23
+ """Main application class for registering and serving agents."""
24
+
25
+ def __init__(self) -> None:
26
+ self._agents: dict[str, RegisteredAgent] = {}
27
+
28
+ def agent(self, config: AgentConfig | None = None, *, name: str | None = None):
29
+ """Decorator to register an async generator function as an agent.
30
+
31
+ Usage:
32
+ @app.agent(AgentConfig(vcpu_count=2))
33
+ async def my_agent(prompt: str):
34
+ yield message
35
+ """
36
+ if config is None:
37
+ config = AgentConfig()
38
+
39
+ def decorator(fn: FunctionType) -> FunctionType:
40
+ agent_name = name or fn.__name__
41
+ module_path = inspect.getfile(fn)
42
+ self._agents[agent_name] = RegisteredAgent(
43
+ name=agent_name,
44
+ fn=fn,
45
+ config=config,
46
+ module_path=module_path,
47
+ )
48
+ return fn
49
+
50
+ return decorator
51
+