nrgrd 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.
nrgrd/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ # Set at build time from the git tag, so there is one source of truth.
5
+ __version__ = version("nrgrd")
6
+ except PackageNotFoundError: # Running from a checkout that was never installed.
7
+ __version__ = "0.0.0"
nrgrd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from nrgrd.app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,29 @@
1
+ from nrgrd.agent.events import (
2
+ AgentCancelled,
3
+ AgentError,
4
+ AgentEvent,
5
+ AgentFinished,
6
+ AgentStarted,
7
+ AssistantChunk,
8
+ PermissionRequested,
9
+ ToolCallDenied,
10
+ ToolCallOutput,
11
+ ToolCallStarted,
12
+ )
13
+ from nrgrd.agent.loop import Agent
14
+ from nrgrd.agent.permissions import PermissionManager
15
+
16
+ __all__ = [
17
+ "Agent",
18
+ "AgentCancelled",
19
+ "AgentError",
20
+ "AgentEvent",
21
+ "AgentFinished",
22
+ "AgentStarted",
23
+ "AssistantChunk",
24
+ "PermissionManager",
25
+ "PermissionRequested",
26
+ "ToolCallDenied",
27
+ "ToolCallOutput",
28
+ "ToolCallStarted",
29
+ ]
nrgrd/agent/events.py ADDED
@@ -0,0 +1,86 @@
1
+ """Events emitted by the agent loop.
2
+
3
+ The TUI (or any other interface) renders these; the agent never renders
4
+ anything itself.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class AgentStarted:
12
+ pass
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AssistantChunk:
17
+ """A streamed slice of the assistant's reply."""
18
+
19
+ delta: str
20
+ text: str
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ToolCallStarted:
25
+ name: str
26
+ arguments: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ToolCallOutput:
31
+ name: str
32
+ result: str
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PermissionRequested:
37
+ """The agent is about to ask whether a gated tool may run."""
38
+
39
+ name: str
40
+ arguments: str
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ToolCallDenied:
45
+ """A tool call the permission system rejected."""
46
+
47
+ name: str
48
+ arguments: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AgentError:
53
+ """A failure, split so the interface can render it usefully.
54
+
55
+ ``message`` says what happened, ``detail`` shows the configuration
56
+ involved, and ``hint`` says what to do next.
57
+ """
58
+
59
+ message: str
60
+ detail: str = ""
61
+ hint: str = ""
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class AgentCancelled:
66
+ """The user interrupted. ``text`` is whatever had been generated."""
67
+
68
+ text: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class AgentFinished:
73
+ text: str
74
+
75
+
76
+ AgentEvent = (
77
+ AgentStarted
78
+ | AgentCancelled
79
+ | AssistantChunk
80
+ | PermissionRequested
81
+ | ToolCallStarted
82
+ | ToolCallOutput
83
+ | ToolCallDenied
84
+ | AgentError
85
+ | AgentFinished
86
+ )
nrgrd/agent/loop.py ADDED
@@ -0,0 +1,182 @@
1
+ """The nrgrd agent runtime: an iterative tool-use loop over an
2
+ OpenAI-compatible model.
3
+
4
+ This module has no dependency on Rich or Textual. It mutates the message
5
+ list it is given and yields events as they happen; it never prints or
6
+ renders. That keeps it usable headlessly (CLI, tests, future interfaces)
7
+ and testable with a mocked model provider.
8
+ """
9
+
10
+ import itertools
11
+ from collections.abc import Iterator
12
+ from typing import Any
13
+
14
+ from nrgrd.agent.events import (
15
+ AgentCancelled,
16
+ AgentError,
17
+ AgentEvent,
18
+ AgentFinished,
19
+ AgentStarted,
20
+ AssistantChunk,
21
+ PermissionRequested,
22
+ ToolCallDenied,
23
+ ToolCallOutput,
24
+ ToolCallStarted,
25
+ )
26
+ from nrgrd.agent.permissions import PermissionManager
27
+ from nrgrd.api.provider import ModelProvider, ProviderError
28
+ from nrgrd.tools.registry import PermissionLevel, ToolRegistry
29
+
30
+ MAX_ITERATIONS = 12
31
+
32
+
33
+ class Agent:
34
+ def __init__(
35
+ self,
36
+ provider: ModelProvider,
37
+ model: str,
38
+ tool_registry: ToolRegistry,
39
+ permissions: PermissionManager | None = None,
40
+ max_iterations: int = MAX_ITERATIONS,
41
+ ) -> None:
42
+ self.provider = provider
43
+ self.model = model
44
+ self.tool_registry = tool_registry
45
+ self.permissions = permissions or PermissionManager()
46
+ self.max_iterations = max_iterations
47
+ self._call_ids = itertools.count(1)
48
+
49
+ def run(self, messages: list[dict[str, Any]]) -> Iterator[AgentEvent]:
50
+ """Run the tool-use loop, mutating ``messages`` in place."""
51
+ yield AgentStarted()
52
+
53
+ for _ in range(self.max_iterations):
54
+ response_text = ""
55
+ tool_calls: dict[int, dict[str, str]] = {}
56
+
57
+ try:
58
+ for delta in self.provider.stream_chat(
59
+ model=self.model,
60
+ messages=messages,
61
+ tools=self.tool_registry.schemas(),
62
+ ):
63
+ if delta.content:
64
+ response_text += delta.content
65
+ yield AssistantChunk(delta.content, response_text)
66
+ for fragment in delta.tool_calls:
67
+ # Some compatible servers omit ``index`` and send
68
+ # each call whole in one chunk. Keying those on
69
+ # None would glue separate calls into one garbled
70
+ # call, so give each its own slot instead.
71
+ slot = (
72
+ fragment.index
73
+ if fragment.index is not None
74
+ else len(tool_calls)
75
+ )
76
+ item = tool_calls.setdefault(
77
+ slot, {"id": "", "name": "", "arguments": ""}
78
+ )
79
+ if fragment.id:
80
+ item["id"] = fragment.id
81
+ if fragment.name:
82
+ item["name"] += fragment.name
83
+ if fragment.arguments:
84
+ item["arguments"] += fragment.arguments
85
+ except KeyboardInterrupt:
86
+ # Keep the partial reply: it is context the user may still
87
+ # want, and dropping it mid-turn loses their work.
88
+ yield AgentCancelled(response_text)
89
+ return
90
+ except ProviderError as error:
91
+ yield AgentError(error.summary, error.detail, error.hint)
92
+ return
93
+ except Exception as error:
94
+ yield AgentError(str(error))
95
+ return
96
+
97
+ if not tool_calls:
98
+ yield AgentFinished(response_text)
99
+ return
100
+
101
+ calls = list(tool_calls.values())
102
+ for call in calls:
103
+ # Tool results are matched to calls by id, and a blank id
104
+ # is rejected. Servers that do not send ids get stable,
105
+ # unique ones.
106
+ if not call["id"]:
107
+ call["id"] = f"call_{next(self._call_ids)}"
108
+
109
+ messages.append(
110
+ {
111
+ "role": "assistant",
112
+ "content": response_text or None,
113
+ "tool_calls": [
114
+ {
115
+ "id": call["id"],
116
+ "type": "function",
117
+ "function": {
118
+ "name": call["name"],
119
+ "arguments": call["arguments"],
120
+ },
121
+ }
122
+ for call in calls
123
+ ],
124
+ }
125
+ )
126
+
127
+ cancelled = False
128
+ for call in calls:
129
+ name, arguments = call["name"], call["arguments"]
130
+ tool = self.tool_registry.get(name)
131
+
132
+ if cancelled:
133
+ # Interrupted earlier in this batch: run nothing further,
134
+ # but still answer the call. An assistant message whose
135
+ # tool_calls are not all answered is rejected by the
136
+ # endpoint, so a half-filled batch would poison the
137
+ # conversation rather than simply end it.
138
+ result = "Cancelled by the user."
139
+ elif tool is None:
140
+ # Nothing would run, so there is nothing to approve:
141
+ # asking "allow delete_file?" for a tool that does not
142
+ # exist only confuses the user. Tell the model instead.
143
+ yield ToolCallStarted(name, arguments)
144
+ result = self.tool_registry.execute(name, arguments)
145
+ yield ToolCallOutput(name, result)
146
+ else:
147
+ level = tool.permission
148
+
149
+ if level is PermissionLevel.ASK:
150
+ # Yielded before asking, so an interface can stop
151
+ # any live rendering first; otherwise a refreshing
152
+ # stream panel draws over the prompt.
153
+ yield PermissionRequested(name, arguments)
154
+
155
+ if not self.permissions.check(name, arguments, level):
156
+ result = "Permission denied by the user."
157
+ yield ToolCallDenied(name, arguments)
158
+ else:
159
+ yield ToolCallStarted(name, arguments)
160
+ try:
161
+ result = self.tool_registry.execute(name, arguments)
162
+ except KeyboardInterrupt:
163
+ cancelled = True
164
+ result = "Cancelled by the user."
165
+ else:
166
+ yield ToolCallOutput(name, result)
167
+
168
+ messages.append(
169
+ {
170
+ "role": "tool",
171
+ "tool_call_id": call["id"],
172
+ "content": result,
173
+ }
174
+ )
175
+
176
+ if cancelled:
177
+ yield AgentCancelled(response_text)
178
+ return
179
+
180
+ yield AgentError(
181
+ "Tool call limit reached; please continue with a narrower request."
182
+ )
@@ -0,0 +1,43 @@
1
+ """Permission gating for tool calls.
2
+
3
+ ``ALLOW`` and ``DENY`` are decided without user interaction. ``ASK`` defers
4
+ to a callback supplied by the interface (TUI or CLI); the manager never
5
+ silently downgrades ``ASK`` to allowed just because no callback was given.
6
+ """
7
+
8
+ from collections.abc import Callable
9
+
10
+ from nrgrd.tools.registry import PermissionLevel
11
+
12
+ PermissionCallback = Callable[[str, str], str]
13
+ """Given a tool name and its raw JSON arguments, return "yes", "no", or "always"."""
14
+
15
+
16
+ class PermissionManager:
17
+ def __init__(self, callback: PermissionCallback | None = None) -> None:
18
+ self.callback = callback
19
+ self._always_allowed: set[str] = set()
20
+
21
+ @property
22
+ def session_grants(self) -> list[str]:
23
+ """Tools the user chose to always allow for this session."""
24
+ return sorted(self._always_allowed)
25
+
26
+ def revoke_session_grants(self) -> None:
27
+ """Forget every "always allow", so those tools ask again."""
28
+ self._always_allowed.clear()
29
+
30
+ def check(self, name: str, arguments: str, level: PermissionLevel) -> bool:
31
+ if level == PermissionLevel.ALLOW:
32
+ return True
33
+ if level == PermissionLevel.DENY:
34
+ return False
35
+ if name in self._always_allowed:
36
+ return True
37
+ if self.callback is None:
38
+ return False
39
+ decision = self.callback(name, arguments)
40
+ if decision == "always":
41
+ self._always_allowed.add(name)
42
+ return True
43
+ return decision == "yes"
nrgrd/api/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from nrgrd.api.openai_compatible import OpenAICompatibleProvider
2
+ from nrgrd.api.provider import (
3
+ ChatDelta,
4
+ ModelProvider,
5
+ ProviderError,
6
+ ToolCallDelta,
7
+ )
8
+
9
+ __all__ = [
10
+ "ChatDelta",
11
+ "ModelProvider",
12
+ "OpenAICompatibleProvider",
13
+ "ProviderError",
14
+ "ToolCallDelta",
15
+ ]
@@ -0,0 +1,186 @@
1
+ """A provider for endpoints that speak the OpenAI HTTP API.
2
+
3
+ This is not OpenAI. It is the *wire format*: a NeuroGrid deployment, vLLM,
4
+ Ollama, LM Studio, or anything else that serves ``/v1/chat/completions``
5
+ works here, because all nrgrd is given is a URL, a key, and a model name.
6
+ """
7
+
8
+ from collections.abc import Iterator, Sequence
9
+ from typing import Any
10
+
11
+ from openai import (
12
+ APIConnectionError,
13
+ APIError,
14
+ APIStatusError,
15
+ APITimeoutError,
16
+ AuthenticationError,
17
+ NotFoundError,
18
+ OpenAI,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ )
22
+
23
+ from nrgrd.api.provider import ChatDelta, ModelProvider, ProviderError, ToolCallDelta
24
+
25
+ DEFAULT_TIMEOUT_SECONDS = 120.0
26
+
27
+
28
+ class OpenAICompatibleProvider(ModelProvider):
29
+ name = "OpenAI-compatible"
30
+
31
+ def __init__(
32
+ self,
33
+ endpoint: str,
34
+ api_key: str,
35
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
36
+ client: Any | None = None,
37
+ ) -> None:
38
+ self.endpoint = endpoint.strip()
39
+ self._api_key = api_key.strip()
40
+ self._client = client or OpenAI(
41
+ base_url=self.endpoint,
42
+ api_key=self._api_key,
43
+ timeout=timeout,
44
+ max_retries=1,
45
+ )
46
+
47
+ def list_models(self) -> list[str]:
48
+ try:
49
+ response = self._client.models.list()
50
+ except Exception as error:
51
+ raise self._describe(error, model=None) from error
52
+ return sorted(item.id for item in response.data)
53
+
54
+ def stream_chat(
55
+ self,
56
+ model: str,
57
+ messages: Sequence[dict[str, Any]],
58
+ tools: Sequence[dict[str, Any]] | None = None,
59
+ ) -> Iterator[ChatDelta]:
60
+ request: dict[str, Any] = {
61
+ "model": model,
62
+ "messages": list(messages),
63
+ "stream": True,
64
+ }
65
+ # Some compatible servers reject an empty tools array, so only send
66
+ # the key when there is something to send.
67
+ if tools:
68
+ request["tools"] = list(tools)
69
+ request["tool_choice"] = "auto"
70
+
71
+ try:
72
+ stream = self._client.chat.completions.create(**request)
73
+ for chunk in stream:
74
+ if not chunk.choices:
75
+ continue
76
+ delta = chunk.choices[0].delta
77
+ calls = tuple(
78
+ ToolCallDelta(
79
+ index=call.index,
80
+ id=call.id,
81
+ name=call.function.name if call.function else None,
82
+ arguments=call.function.arguments if call.function else None,
83
+ )
84
+ for call in (delta.tool_calls or [])
85
+ )
86
+ if delta.content or calls:
87
+ yield ChatDelta(content=delta.content, tool_calls=calls)
88
+ except ProviderError:
89
+ raise
90
+ except Exception as error:
91
+ raise self._describe(error, model=model) from error
92
+
93
+ def complete_chat(
94
+ self,
95
+ model: str,
96
+ messages: Sequence[dict[str, Any]],
97
+ ) -> str:
98
+ try:
99
+ response = self._client.chat.completions.create(
100
+ model=model,
101
+ messages=list(messages),
102
+ stream=False,
103
+ )
104
+ except Exception as error:
105
+ raise self._describe(error, model=model) from error
106
+ return response.choices[0].message.content or ""
107
+
108
+ def _redact(self, text: str) -> str:
109
+ """Strip the API key out of anything on its way to the user."""
110
+ if self._api_key and self._api_key in text:
111
+ return text.replace(self._api_key, "***")
112
+ return text
113
+
114
+ def _configuration(self, model: str | None) -> str:
115
+ lines = [f"Endpoint: {self.endpoint or '(not set)'}"]
116
+ if model:
117
+ lines.append(f"Model: {model}")
118
+ return "\n".join(lines)
119
+
120
+ def _describe(self, error: Exception, model: str | None) -> ProviderError:
121
+ """Turn a transport-level failure into something actionable."""
122
+ detail = self._configuration(model)
123
+ change = "Run /config edit to change the configuration."
124
+
125
+ if isinstance(error, AuthenticationError):
126
+ return ProviderError(
127
+ "The endpoint rejected the API key.",
128
+ detail,
129
+ f"Check the key issued with this deployment. {change}",
130
+ )
131
+ if isinstance(error, PermissionDeniedError):
132
+ return ProviderError(
133
+ "The endpoint refused this request (HTTP 403).",
134
+ detail,
135
+ f"The key may not cover this model or route. {change}",
136
+ )
137
+ if isinstance(error, NotFoundError):
138
+ return ProviderError(
139
+ "The endpoint returned HTTP 404 (not found).",
140
+ detail,
141
+ "Check the endpoint path (it usually ends in /v1) and that "
142
+ f"the model name exists. {change}",
143
+ )
144
+ if isinstance(error, RateLimitError):
145
+ return ProviderError(
146
+ "The endpoint is rate limiting requests (HTTP 429).",
147
+ detail,
148
+ "Wait a moment and try again.",
149
+ )
150
+ if isinstance(error, APITimeoutError):
151
+ return ProviderError(
152
+ "The endpoint timed out.",
153
+ detail,
154
+ "The deployment may be loading the model or overloaded. "
155
+ "Try again shortly.",
156
+ )
157
+ if isinstance(error, APIConnectionError):
158
+ return ProviderError(
159
+ "Could not reach the endpoint.",
160
+ detail,
161
+ "Check the URL is right, the deployment is still running, "
162
+ f"and that you are online. {change}",
163
+ )
164
+ if isinstance(error, APIStatusError):
165
+ return ProviderError(
166
+ f"The endpoint returned HTTP {error.status_code}.",
167
+ detail,
168
+ self._redact(str(getattr(error, "message", "")).strip()) or change,
169
+ )
170
+ if not isinstance(error, APIError):
171
+ # The request itself went through, but the reply could not be
172
+ # read as an API response. Almost always the URL points at
173
+ # something that is not an OpenAI-compatible API — a web UI, a
174
+ # proxy error page, or simply the wrong port.
175
+ return ProviderError(
176
+ "The endpoint replied with something that is not an "
177
+ "OpenAI-compatible API response.",
178
+ detail,
179
+ "Check the URL points at the API itself (it usually ends "
180
+ f"in /v1) rather than a web page. {change}",
181
+ )
182
+ return ProviderError(
183
+ "The model request failed.",
184
+ detail,
185
+ self._redact(str(error)),
186
+ )
nrgrd/api/provider.py ADDED
@@ -0,0 +1,78 @@
1
+ """The model provider interface the agent runtime talks to.
2
+
3
+ The agent never learns which service is behind the endpoint. A provider owns
4
+ HTTP communication, authentication, streaming, and turning wire-level
5
+ failures into :class:`ProviderError`s a user can act on; the agent owns
6
+ context, tools, permissions, and iteration.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from collections.abc import Iterator, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ToolCallDelta:
17
+ """A slice of one streamed tool call.
18
+
19
+ Providers stream tool calls in fragments: the name arrives in one
20
+ chunk, the arguments across several more. ``index`` identifies which
21
+ call a fragment belongs to.
22
+ """
23
+
24
+ index: int
25
+ id: str | None = None
26
+ name: str | None = None
27
+ arguments: str | None = None
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ChatDelta:
32
+ """One streamed step of a reply: text, tool-call fragments, or both."""
33
+
34
+ content: str | None = None
35
+ tool_calls: tuple[ToolCallDelta, ...] = ()
36
+
37
+
38
+ class ProviderError(Exception):
39
+ """A model request failed, described in terms the user can act on.
40
+
41
+ ``summary`` says what happened, ``detail`` shows the configuration the
42
+ request used, and ``hint`` says what to do about it. None of them may
43
+ ever contain the API key.
44
+ """
45
+
46
+ def __init__(self, summary: str, detail: str = "", hint: str = "") -> None:
47
+ super().__init__(summary)
48
+ self.summary = summary
49
+ self.detail = detail
50
+ self.hint = hint
51
+
52
+
53
+ class ModelProvider(ABC):
54
+ """Any endpoint the agent can talk to."""
55
+
56
+ name: str = "model provider"
57
+ endpoint: str = ""
58
+
59
+ @abstractmethod
60
+ def list_models(self) -> list[str]:
61
+ """Return the model ids the endpoint exposes."""
62
+
63
+ @abstractmethod
64
+ def stream_chat(
65
+ self,
66
+ model: str,
67
+ messages: Sequence[dict[str, Any]],
68
+ tools: Sequence[dict[str, Any]] | None = None,
69
+ ) -> Iterator[ChatDelta]:
70
+ """Stream a reply, yielding normalized deltas as they arrive."""
71
+
72
+ @abstractmethod
73
+ def complete_chat(
74
+ self,
75
+ model: str,
76
+ messages: Sequence[dict[str, Any]],
77
+ ) -> str:
78
+ """Return a complete reply in one shot (used for compaction)."""