nitpick-core 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.
@@ -0,0 +1,51 @@
1
+ """nitpick-core — the agent runtime for nitpick.
2
+
3
+ Loads agent definitions from a minidb registry, dispatches them through
4
+ Prefect, and serves the voice rooms so people can talk to them.
5
+
6
+ POST /tasks {"handle": "assistant"} -> a session
7
+ WS /ws -> talk to it
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from .builder import AgentSpec, build_agent, build_spec
12
+ from .client import CoreClient, CoreError
13
+ from .config import Config, load_config
14
+ from .errors import (
15
+ AgentNotFound,
16
+ BuildError,
17
+ DispatchError,
18
+ ModelNotFound,
19
+ NitpickCoreError,
20
+ RegistryError,
21
+ SessionNotFound,
22
+ )
23
+ from .registry import Registry, get_registry, resolve_agent
24
+ from .runtime import AgentRuntime, Session, SessionStatus, get_runtime
25
+
26
+ __version__ = "0.1.0"
27
+
28
+ __all__ = [
29
+ "AgentNotFound",
30
+ "AgentRuntime",
31
+ "AgentSpec",
32
+ "BuildError",
33
+ "Config",
34
+ "CoreClient",
35
+ "CoreError",
36
+ "DispatchError",
37
+ "ModelNotFound",
38
+ "NitpickCoreError",
39
+ "Registry",
40
+ "RegistryError",
41
+ "Session",
42
+ "SessionNotFound",
43
+ "SessionStatus",
44
+ "__version__",
45
+ "build_agent",
46
+ "build_spec",
47
+ "get_registry",
48
+ "get_runtime",
49
+ "load_config",
50
+ "resolve_agent",
51
+ ]
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
nitpick_core/api.py ADDED
@@ -0,0 +1,298 @@
1
+ """The nitpick-core HTTP API.
2
+
3
+ Two things live on one port:
4
+
5
+ * the **control API** — dispatch tasks, look at sessions, browse the agent
6
+ registry
7
+ * the **signaling WebSocket** at ``/ws`` — the nitpick room protocol, so
8
+ somebody can talk to a running agent
9
+
10
+ The browser client is served at ``/``.
11
+
12
+ POST /tasks {"handle": "assistant", "room_id": "demo"}
13
+ -> 202 {"id": "ses-...", "status": "queued", ...}
14
+
15
+ GET /tasks/{id} the session
16
+ GET /sessions every session
17
+ GET /agents the agent registry
18
+ GET /agents/{handle} one agent, resolved
19
+ GET /health
20
+ WS /ws the signaling socket
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import logging
26
+ from contextlib import asynccontextmanager
27
+ from typing import Any
28
+
29
+ from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
30
+ from fastapi.encoders import jsonable_encoder
31
+ from fastapi.responses import HTMLResponse, JSONResponse
32
+ from pydantic import BaseModel, Field
33
+
34
+ from nitpick.signaling.server import SignalingServer
35
+
36
+ from .config import Config, load_config
37
+ from .errors import (
38
+ AgentNotFound,
39
+ BuildError,
40
+ DispatchError,
41
+ ModelNotFound,
42
+ RegistryError,
43
+ SessionNotFound,
44
+ )
45
+ from .flows import run_flow
46
+ from .registry import get_registry
47
+ from .runtime import AgentRuntime, get_runtime
48
+
49
+ log = logging.getLogger(__name__)
50
+
51
+ __all__ = ["create_app"]
52
+
53
+
54
+ class TaskRequest(BaseModel):
55
+ """A request to run an agent."""
56
+
57
+ handle: str = Field(..., description="The agent's handle in the registry")
58
+ room_id: str | None = Field(None, description="The room to join. Defaults to the configured one.")
59
+ overrides: dict[str, Any] = Field(default_factory=dict, description="Overrides for the built agent.")
60
+ metadata: dict[str, Any] = Field(default_factory=dict, description="Arbitrary data to record on the session.")
61
+ wait: bool = Field(False, description="Wait for the session to finish before replying.")
62
+
63
+
64
+ def _encoder() -> dict[type, Any]:
65
+ """Encoders for the types the registry stores.
66
+
67
+ minidb documents carry an ``ObjectId`` and a ``created_at`` timestamp;
68
+ FastAPI knows about the timestamp but not about the object id.
69
+ """
70
+ encoders: dict[type, Any] = {}
71
+ try:
72
+ from minidb import ObjectId
73
+
74
+ encoders[ObjectId] = str
75
+ except ImportError: # pragma: no cover - minidb is a hard dependency
76
+ pass
77
+ return encoders
78
+
79
+
80
+ def create_app(
81
+ config: Config | None = None,
82
+ *,
83
+ runtime: AgentRuntime | None = None,
84
+ ) -> FastAPI:
85
+ """Build the application."""
86
+ settings = config or load_config()
87
+ agent_runtime = runtime or get_runtime(settings)
88
+
89
+ # Configure Prefect before anything else touches it, so its state
90
+ # lives where this service can find it.
91
+ from .flows import prefect_settings
92
+
93
+ prefect_settings(settings)
94
+
95
+ # The signaling server shares the runtime's room manager, so every
96
+ # session is visible on the same socket.
97
+ signaling = SignalingServer(agent_runtime.manager)
98
+
99
+ # ------------------------------------------------------------------
100
+ # Lifecycle
101
+ # ------------------------------------------------------------------
102
+
103
+ @asynccontextmanager
104
+ async def lifespan(_app: FastAPI):
105
+ await agent_runtime.start()
106
+ try:
107
+ yield
108
+ finally:
109
+ await agent_runtime.stop()
110
+
111
+ app = FastAPI(
112
+ title="nitpick-core", version="0.1.0", lifespan=lifespan
113
+ )
114
+ app.state.config = settings
115
+ app.state.runtime = agent_runtime
116
+ app.state.signaling = signaling
117
+
118
+ # ------------------------------------------------------------------
119
+ # Control API
120
+ # ------------------------------------------------------------------
121
+
122
+ @app.get("/health")
123
+ async def health() -> JSONResponse:
124
+ return JSONResponse(
125
+ {
126
+ "status": "ok",
127
+ "config": settings.to_dict(),
128
+ "sessions": len(agent_runtime.list_sessions()),
129
+ }
130
+ )
131
+
132
+ @app.post("/tasks", status_code=202)
133
+ async def create_task(payload: TaskRequest) -> JSONResponse:
134
+ """Dispatch a task. The agent runtime picks it up."""
135
+ try:
136
+ # The flow is synchronous and takes about a second while Prefect
137
+ # records the run, so it goes in a thread rather than blocking
138
+ # the event loop.
139
+ session = await asyncio.to_thread(
140
+ run_flow,
141
+ payload.handle,
142
+ room_id=payload.room_id,
143
+ overrides=payload.overrides or None,
144
+ metadata=payload.metadata or None,
145
+ runtime=agent_runtime,
146
+ config=settings,
147
+ )
148
+ except DispatchError as exc:
149
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
150
+ except (AgentNotFound, ModelNotFound) as exc:
151
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
152
+ except BuildError as exc:
153
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
154
+ except RegistryError as exc:
155
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
156
+
157
+ return JSONResponse(session, status_code=202)
158
+
159
+ @app.get("/tasks/{session_id}")
160
+ async def get_task(session_id: str) -> JSONResponse:
161
+ """One session."""
162
+ try:
163
+ session = agent_runtime.get_session(session_id)
164
+ except SessionNotFound as exc:
165
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
166
+ return JSONResponse(session.to_dict())
167
+
168
+ @app.get("/sessions")
169
+ async def list_sessions() -> JSONResponse:
170
+ """Every session."""
171
+ sessions = [session.to_dict() for session in agent_runtime.list_sessions()]
172
+ return JSONResponse({"count": len(sessions), "results": sessions})
173
+
174
+ @app.get("/sessions/{session_id}")
175
+ async def get_session(session_id: str) -> JSONResponse:
176
+ """One session."""
177
+ try:
178
+ session = agent_runtime.get_session(session_id)
179
+ except SessionNotFound as exc:
180
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
181
+ return JSONResponse(session.to_dict())
182
+
183
+ @app.get("/agents")
184
+ async def list_agents() -> JSONResponse:
185
+ """The agent registry."""
186
+ try:
187
+ agents = get_registry(settings).list_agents()
188
+ except RegistryError as exc:
189
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
190
+ return JSONResponse(
191
+ jsonable_encoder(
192
+ {"count": len(agents), "results": agents}, custom_encoder=_encoder()
193
+ )
194
+ )
195
+
196
+ @app.get("/agents/{handle}")
197
+ async def get_agent(handle: str) -> JSONResponse:
198
+ """One agent, with the models it names resolved."""
199
+ try:
200
+ resolved = get_registry(settings).resolve_agent(handle)
201
+ except AgentNotFound as exc:
202
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
203
+ except ModelNotFound as exc:
204
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
205
+ except RegistryError as exc:
206
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
207
+ return JSONResponse(jsonable_encoder(resolved, custom_encoder=_encoder()))
208
+
209
+ # ------------------------------------------------------------------
210
+ # The signaling socket and the browser client
211
+ # ------------------------------------------------------------------
212
+
213
+ @app.websocket("/ws")
214
+ async def websocket_endpoint(websocket: WebSocket) -> None:
215
+ await _serve_signaling(signaling, websocket)
216
+
217
+ @app.get("/", response_class=HTMLResponse)
218
+ async def index() -> HTMLResponse:
219
+ html = _load_web_client(settings)
220
+ if html is None:
221
+ return HTMLResponse(
222
+ "<h1>nitpick-core</h1><p>The browser client is not installed.</p>"
223
+ )
224
+ return HTMLResponse(html)
225
+
226
+ return app
227
+
228
+
229
+ async def _serve_signaling(signaling: SignalingServer, websocket: WebSocket) -> None:
230
+ """Run nitpick's WebSocket protocol on this app's socket."""
231
+ import json
232
+
233
+ await websocket.accept()
234
+ participant = signaling.manager.register_participant(websocket)
235
+ await websocket.send_json(
236
+ {
237
+ "type": "connected",
238
+ "participant_id": participant.participant_id,
239
+ "stt": {"provider": "pcm", "transport": "audio_pcm"},
240
+ "audio": {"output_sample_rate": signaling.audio_output_sample_rate},
241
+ }
242
+ )
243
+ try:
244
+ while True:
245
+ raw = await websocket.receive_text()
246
+ try:
247
+ payload = json.loads(raw)
248
+ except json.JSONDecodeError:
249
+ await websocket.send_json(
250
+ {
251
+ "type": "error",
252
+ "code": "invalid_json",
253
+ "message": "Payload is not valid JSON",
254
+ }
255
+ )
256
+ continue
257
+ if not isinstance(payload, dict):
258
+ await websocket.send_json(
259
+ {
260
+ "type": "error",
261
+ "code": "invalid_payload",
262
+ "message": "Payload must be a JSON object",
263
+ }
264
+ )
265
+ continue
266
+ response = await signaling.handle_message(websocket, payload)
267
+ if response is not None:
268
+ await websocket.send_json(response)
269
+ except WebSocketDisconnect:
270
+ pass
271
+ finally:
272
+ signaling.manager.unregister_participant_by_id(participant.participant_id)
273
+
274
+
275
+ def _load_web_client(config: Config) -> str | None:
276
+ """The browser client.
277
+
278
+ nitpick ships one; ``NITPICK_CORE_WEB_ROOT`` overrides it.
279
+ """
280
+ from pathlib import Path
281
+
282
+ if config.web_root:
283
+ path = Path(config.web_root)
284
+ if path.is_dir():
285
+ path = path / "index.html"
286
+ if path.exists():
287
+ return path.read_text(encoding="utf-8")
288
+ return None
289
+
290
+ try:
291
+ from nitpick.signaling import server as nitpick_server
292
+ except ImportError: # pragma: no cover - nitpick is a hard dependency
293
+ return None
294
+
295
+ path = Path(nitpick_server.__file__).resolve().parent.parent / "web" / "index.html"
296
+ if path.exists():
297
+ return path.read_text(encoding="utf-8")
298
+ return None
@@ -0,0 +1,292 @@
1
+ """Turning registry documents into a runnable nitpick agent.
2
+
3
+ The registry stores *what* an agent is — its prompt and the models it talks
4
+ through — in a provider-neutral way. This module translates that into the
5
+ provider-specific strings nitpick's ``Agent`` facade expects.
6
+
7
+ The translation is driven by the model's ``handle``, which names the
8
+ provider (``openai``, ``deepgram``, ``sarvam``, ...):
9
+
10
+ ============ ========== ==========================================
11
+ Registry nitpick How the value is derived
12
+ ============ ========== ==========================================
13
+ ``llm`` ``llm`` ``openai:<model>``
14
+ ``stt`` ``stt`` ``openai`` / ``deepgram`` / ``sarvam``
15
+ ``tts`` ``tts`` ``openai:<voice>``
16
+ ============ ========== ==========================================
17
+
18
+ A model may override the derived value through its ``config`` — for
19
+ example ``{"model": "gpt-4o-mini"}`` on an LLM, or ``{"voices": ["nova"]}``
20
+ on a TTS model.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+
28
+ from nitpick import Agent
29
+
30
+ from .errors import BuildError
31
+
32
+ log = logging.getLogger(__name__)
33
+
34
+ __all__ = ["AgentSpec", "build_agent", "build_spec", "provider_for"]
35
+
36
+
37
+ #: Handles nitpick can talk to directly.
38
+ SUPPORTED_PROVIDERS = {
39
+ "openai": "openai",
40
+ "deepgram": "deepgram",
41
+ "sarvam": "sarvam",
42
+ "ollama": "openai", # OpenAI-compatible wire format
43
+ "vllm": "openai",
44
+ "together": "openai",
45
+ "groq": "openai",
46
+ }
47
+
48
+ #: A fallback for registries where a model's handle was left as its slug
49
+ #: instead of naming the provider. The handle is the right way to say this;
50
+ #: the table only rescues data that was seeded before the handle was
51
+ #: introduced, or written by hand.
52
+ KNOWN_MODEL_PROVIDERS = {
53
+ "gpt-4o": "openai",
54
+ "gpt-4o-mini": "openai",
55
+ "gpt-4.1": "openai",
56
+ "gpt-4-turbo": "openai",
57
+ "o1": "openai",
58
+ "o3": "openai",
59
+ "alloy": "openai",
60
+ "echo": "openai",
61
+ "fable": "openai",
62
+ "onyx": "openai",
63
+ "nova": "openai",
64
+ "whisper": "openai",
65
+ "deepgram": "deepgram",
66
+ "deepgram-nova": "deepgram",
67
+ "nova-3": "deepgram",
68
+ "saaras": "sarvam",
69
+ "saaras:v3": "sarvam",
70
+ "sarvam": "sarvam",
71
+ }
72
+
73
+
74
+ @dataclass
75
+ class AgentSpec:
76
+ """Everything needed to build one agent, resolved from the registry."""
77
+
78
+ handle: str
79
+ name: str
80
+ prompt: str
81
+ stt: str
82
+ llm: str
83
+ tts: str
84
+ language: str | None = None
85
+ #: The base URL of the LLM endpoint, for OpenAI-compatible servers.
86
+ llm_base_url: str | None = None
87
+ room_id: str = "demo"
88
+ config: dict[str, Any] = field(default_factory=dict)
89
+ #: The raw documents, kept for the session record.
90
+ documents: dict[str, Any] = field(default_factory=dict)
91
+
92
+ def to_dict(self) -> dict[str, Any]:
93
+ return {
94
+ "handle": self.handle,
95
+ "name": self.name,
96
+ "prompt": self.prompt,
97
+ "stt": self.stt,
98
+ "llm": self.llm,
99
+ "tts": self.tts,
100
+ "language": self.language,
101
+ "llm_base_url": self.llm_base_url,
102
+ "room_id": self.room_id,
103
+ "config": self.config,
104
+ }
105
+
106
+
107
+ def provider_for(model: dict[str, Any]) -> str:
108
+ """The nitpick provider name for a registry model.
109
+
110
+ The model's ``handle`` names the provider. A model whose provider
111
+ nitpick does not know about is only usable if it declares an
112
+ OpenAI-compatible ``base_url``.
113
+ """
114
+ handle = str(model.get("handle") or "").strip().lower()
115
+ config = model.get("config") or {}
116
+
117
+ if handle in SUPPORTED_PROVIDERS:
118
+ return SUPPORTED_PROVIDERS[handle]
119
+
120
+ # The handle was left as the model's own name — fall back to a lookup
121
+ # by name, so a registry written before the handle existed still works.
122
+ for key in ("slug", "name"):
123
+ candidate = str(model.get(key) or "").strip().lower()
124
+ if candidate in KNOWN_MODEL_PROVIDERS:
125
+ return KNOWN_MODEL_PROVIDERS[candidate]
126
+
127
+ if config.get("base_url"):
128
+ # Any OpenAI-compatible endpoint works.
129
+ return "openai"
130
+
131
+ raise BuildError(
132
+ f"the model {model.get('slug')!r} uses the provider {handle!r}, which "
133
+ "nitpick does not support. Supported providers are: "
134
+ + ", ".join(sorted(set(SUPPORTED_PROVIDERS.values())))
135
+ + ". Give the model a 'base_url' in its config if it speaks the "
136
+ "OpenAI wire format, or set its handle to the provider's name."
137
+ )
138
+
139
+
140
+ def _model_id(model: dict[str, Any], fallback: str) -> str:
141
+ """The provider-specific model identifier."""
142
+ config = model.get("config") or {}
143
+ for key in ("model", "model_id", "id"):
144
+ value = config.get(key)
145
+ if isinstance(value, str) and value:
146
+ return value
147
+ # The slug is a better identifier than the display name.
148
+ slug = model.get("slug")
149
+ if isinstance(slug, str) and slug:
150
+ return slug
151
+ return fallback
152
+
153
+
154
+ def _voice_for(model: dict[str, Any]) -> str:
155
+ """The voice a TTS model should speak with."""
156
+ config = model.get("config") or {}
157
+ for key in ("voice", "voices"):
158
+ value = config.get(key)
159
+ if isinstance(value, str) and value:
160
+ return value
161
+ if isinstance(value, list) and value:
162
+ first = value[0]
163
+ if isinstance(first, str) and first:
164
+ return first
165
+ slug = model.get("slug")
166
+ return slug if isinstance(slug, str) and slug else "alloy"
167
+
168
+
169
+ def _stt_spec(model: dict[str, Any]) -> str:
170
+ """The ``stt`` argument for nitpick's Agent."""
171
+ provider = provider_for(model)
172
+ if provider == "openai":
173
+ return "openai"
174
+ if provider == "deepgram":
175
+ return "deepgram"
176
+ if provider == "sarvam":
177
+ return "sarvam"
178
+ raise BuildError(f"unsupported speech-to-text provider {provider!r}")
179
+
180
+
181
+ def _llm_spec(model: dict[str, Any]) -> str:
182
+ """The ``llm`` argument for nitpick's Agent."""
183
+ provider = provider_for(model)
184
+ if provider != "openai":
185
+ raise BuildError(
186
+ f"unsupported language-model provider {provider!r} — nitpick speaks "
187
+ "the OpenAI wire format"
188
+ )
189
+ return f"openai:{_model_id(model, 'gpt-4o-mini')}"
190
+
191
+
192
+ def _tts_spec(model: dict[str, Any]) -> str:
193
+ """The ``tts`` argument for nitpick's Agent."""
194
+ provider = provider_for(model)
195
+ if provider != "openai":
196
+ raise BuildError(f"unsupported text-to-speech provider {provider!r}")
197
+ return f"openai:{_voice_for(model)}"
198
+
199
+
200
+ def build_spec(
201
+ resolved: dict[str, Any],
202
+ *,
203
+ room_id: str = "demo",
204
+ overrides: dict[str, Any] | None = None,
205
+ ) -> AgentSpec:
206
+ """Build the spec for one agent from its resolved documents."""
207
+ agent_document = resolved["agent"]
208
+ config = dict(agent_document.get("config") or {})
209
+ overrides = overrides or {}
210
+
211
+ stt_model = resolved.get("stt")
212
+ llm_model = resolved.get("llm")
213
+ tts_model = resolved.get("tts")
214
+
215
+ if stt_model is None:
216
+ raise BuildError(
217
+ f"the agent {agent_document.get('slug')!r} has no speech-to-text model"
218
+ )
219
+ if llm_model is None:
220
+ raise BuildError(
221
+ f"the agent {agent_document.get('slug')!r} has no language model"
222
+ )
223
+ if tts_model is None:
224
+ raise BuildError(
225
+ f"the agent {agent_document.get('slug')!r} has no text-to-speech model"
226
+ )
227
+
228
+ stt = overrides.get("stt") or _stt_spec(stt_model)
229
+ llm = overrides.get("llm") or _llm_spec(llm_model)
230
+ tts = overrides.get("tts") or _tts_spec(tts_model)
231
+
232
+ language = (
233
+ overrides.get("language")
234
+ or config.get("language")
235
+ or (stt_model.get("config") or {}).get("language")
236
+ )
237
+
238
+ # A model may point at an OpenAI-compatible endpoint — a self-hosted
239
+ # model, or a gateway. That is how a provider nitpick does not know
240
+ # about can still be used.
241
+ llm_base_url = (
242
+ overrides.get("llm_base_url")
243
+ or config.get("base_url")
244
+ or (llm_model.get("config") or {}).get("base_url")
245
+ )
246
+
247
+ return AgentSpec(
248
+ handle=str(agent_document.get("handle") or agent_document.get("slug") or ""),
249
+ name=str(agent_document.get("name") or agent_document.get("slug") or "Agent"),
250
+ prompt=str(agent_document.get("prompt") or ""),
251
+ stt=stt,
252
+ llm=llm,
253
+ tts=tts,
254
+ language=language if isinstance(language, str) else None,
255
+ llm_base_url=llm_base_url if isinstance(llm_base_url, str) else None,
256
+ room_id=room_id,
257
+ config=config,
258
+ documents=resolved,
259
+ )
260
+
261
+
262
+ def build_agent(
263
+ spec: AgentSpec,
264
+ *,
265
+ manager: Any = None,
266
+ session_id: str | None = None,
267
+ ) -> Agent:
268
+ """Build the runnable nitpick agent for a spec."""
269
+ kwargs: dict[str, Any] = {
270
+ "name": spec.name,
271
+ "prompt": spec.prompt or None,
272
+ "stt": spec.stt,
273
+ "llm": spec.llm,
274
+ "tts": spec.tts,
275
+ }
276
+ if spec.language:
277
+ kwargs["language"] = spec.language
278
+ if spec.llm_base_url:
279
+ kwargs["llm_base_url"] = spec.llm_base_url
280
+ if manager is not None:
281
+ kwargs["manager"] = manager
282
+ if session_id:
283
+ kwargs["session_id"] = session_id
284
+
285
+ # The agent's own config may carry pipeline settings.
286
+ config = spec.config or {}
287
+ if "vad" in config:
288
+ kwargs["vad"] = config["vad"]
289
+ if "allow_interruptions" in config:
290
+ kwargs["allow_interruptions"] = bool(config["allow_interruptions"])
291
+
292
+ return Agent(**kwargs)