nodloop 0.6.6__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.
nodloop/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Record AI runs and the verdicts people give on them, and hand the knowledge they approve to the next run in the same place"""
2
+
3
+ from .client import Client, Decision, Item, Knowledge, ToolError
4
+
5
+ __all__ = ["Client", "Decision", "Item", "Knowledge", "ToolError"]
nodloop/binary.py ADDED
@@ -0,0 +1,67 @@
1
+ """Finds the nodloop binary the client runs and fetches the release one when there is none."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import io
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import tarfile
11
+ import urllib.request
12
+ from pathlib import Path
13
+
14
+ REPO = "jeon-jihyeon/nodloop"
15
+
16
+
17
+ def home_bin() -> Path:
18
+ return Path.home() / ".nodloop" / "bin"
19
+
20
+
21
+ def find(version: str) -> str:
22
+ """The binary to run.
23
+
24
+ 1. NODLOOP_BIN when it is set
25
+ 2. the stable link the Claude Code plugin keeps under ~/.nodloop/bin
26
+ 3. nodloop on PATH
27
+ 4. the release of the version, fetched once into ~/.nodloop/bin/v<version> and checked against its checksums
28
+ """
29
+ if env := os.environ.get("NODLOOP_BIN"):
30
+ return env
31
+ stable = home_bin() / "nodloop"
32
+ if stable.exists():
33
+ return str(stable)
34
+ if found := shutil.which("nodloop"):
35
+ return found
36
+ return str(fetch(version))
37
+
38
+
39
+ def archive_name() -> str:
40
+ system = platform.system().lower()
41
+ machine = {"x86_64": "amd64", "amd64": "amd64", "arm64": "arm64", "aarch64": "arm64"}[platform.machine().lower()]
42
+ return f"nodloop_{system}_{machine}.tar.gz"
43
+
44
+
45
+ def fetch(version: str) -> Path:
46
+ """The release binary of the version, refused when its archive does not match checksums.txt."""
47
+ target = home_bin() / f"v{version}" / "nodloop"
48
+ if target.exists():
49
+ return target
50
+ base = f"https://github.com/{REPO}/releases/download/v{version}"
51
+ name = archive_name()
52
+ archive = urllib.request.urlopen(f"{base}/{name}", timeout=60).read()
53
+ sums = urllib.request.urlopen(f"{base}/checksums.txt", timeout=30).read().decode()
54
+ want = next((line.split()[0] for line in sums.splitlines() if line.split()[1:] == [name]), "")
55
+ if not want or hashlib.sha256(archive).hexdigest() != want:
56
+ raise RuntimeError(f"checksum of {base}/{name} does not match checksums.txt")
57
+ with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
58
+ member = tar.extractfile("nodloop")
59
+ if member is None:
60
+ raise RuntimeError(f"{name} holds no nodloop binary")
61
+ data = member.read()
62
+ target.parent.mkdir(parents=True, exist_ok=True)
63
+ partial = target.with_suffix(".partial")
64
+ partial.write_bytes(data)
65
+ partial.chmod(0o755)
66
+ partial.replace(target)
67
+ return target
@@ -0,0 +1,65 @@
1
+ """Claude Agent SDK hooks for nodloop, the same loop the Claude Code plugin runs
2
+
3
+ 1. UserPromptSubmit adds the approved items of the place as context
4
+ 2. Stop records the last answer as a run naming the items it got
5
+ 3. PreToolUse denies a call an approved veto blocks and asks about one it hands to a person
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from claude_agent_sdk import HookMatcher
13
+
14
+ from .client import Client, Item
15
+
16
+ Labels = dict[str, list[str]]
17
+
18
+
19
+ class NodloopHooks:
20
+ """Pass hooks() as the hooks of ClaudeAgentOptions
21
+
22
+ Use one instance per conversation because it keeps the items of the turn and the id of the last run
23
+ """
24
+
25
+ def __init__(self, client: Client, producer: str, labels: Labels | None = None) -> None:
26
+ self.client = client
27
+ self.producer = producer
28
+ self.labels = labels or {}
29
+ self.applied: list[Item] = []
30
+ # The run the last answer was recorded as so a verdict can cite it
31
+ self.run: str = ""
32
+
33
+ def hooks(self) -> dict[str, list[HookMatcher]]:
34
+ return {
35
+ "UserPromptSubmit": [HookMatcher(hooks=[self.prompt])],
36
+ "Stop": [HookMatcher(hooks=[self.stop])],
37
+ "PreToolUse": [HookMatcher(hooks=[self.pre_tool_use])],
38
+ }
39
+
40
+ async def prompt(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
41
+ knowledge = await self.client.knowledge(self.producer, self.labels)
42
+ self.applied = knowledge.items
43
+ if not knowledge.text:
44
+ return {}
45
+ return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": knowledge.text}}
46
+
47
+ async def stop(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
48
+ # Claude Code sends the last answer under last_assistant_message
49
+ # A turn without one records nothing
50
+ answer = input_data.get("last_assistant_message") or ""
51
+ if answer.strip():
52
+ self.run = await self.client.record(self.producer, answer, self.labels, self.applied)
53
+ return {}
54
+
55
+ async def pre_tool_use(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
56
+ decision = await self.client.check_call(input_data["tool_name"], input_data.get("tool_input") or {}, self.producer)
57
+ if decision.allowed:
58
+ return {}
59
+ return {
60
+ "hookSpecificOutput": {
61
+ "hookEventName": "PreToolUse",
62
+ "permissionDecision": "deny" if decision.action == "block" else "ask",
63
+ "permissionDecisionReason": f"nodloop veto {decision.veto}: {decision.reason}",
64
+ }
65
+ }
nodloop/client.py ADDED
@@ -0,0 +1,206 @@
1
+ """The client of the nodloop MCP tools over a local binary or a nodloop server"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from contextlib import AsyncExitStack
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+ from mcp import ClientSession, StdioServerParameters
12
+ from mcp.client.stdio import stdio_client
13
+ from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
14
+
15
+ from . import binary
16
+
17
+ __all__ = ["Client", "Decision", "Item", "Knowledge", "ToolError"]
18
+
19
+ # The binary release the client fetches when no nodloop is installed
20
+ VERSION = "0.6.6"
21
+
22
+
23
+ class ToolError(RuntimeError):
24
+ """A tool answered an error such as a refused proposal"""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Item:
29
+ """One approved knowledge version a run receives"""
30
+
31
+ id: str
32
+ version: int
33
+ kind: str
34
+ content: str
35
+ scope: dict[str, Any] = field(default_factory=dict)
36
+
37
+ @property
38
+ def ref(self) -> dict[str, Any]:
39
+ return {"id": self.id, "version": self.version}
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Decision:
44
+ """What a tool call may do: allow, block or ask"""
45
+
46
+ action: str
47
+ veto: str = ""
48
+ reason: str = ""
49
+
50
+ @property
51
+ def allowed(self) -> bool:
52
+ return self.action == "allow"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Knowledge:
57
+ """The approved items a run receives and the text that carries them into its prompt"""
58
+
59
+ items: list[Item]
60
+ # Empty without items so a prompt gains nothing
61
+ text: str
62
+
63
+
64
+ class Client:
65
+ """The nodloop tools of one process or of a nodloop server
66
+
67
+ 1. by default it starts `nodloop mcp` over stdio, so nothing runs as a server
68
+ 2. record_dir names the records and else NODLOOP_RECORD_DIR, config.json or ~/.nodloop/records decide as in the CLI
69
+ 3. url and key connect to `nodloop server serve` instead, where the key decides the tenant and the role
70
+ 4. use it as an async context manager so the process or the connection ends with the block
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ record_dir: str | None = None,
76
+ binary_path: str | None = None,
77
+ url: str | None = None,
78
+ key: str | None = None,
79
+ ) -> None:
80
+ self._record_dir = record_dir
81
+ self._binary = binary_path
82
+ self._url = url
83
+ self._key = key
84
+ self._stack = AsyncExitStack()
85
+ self._session: ClientSession | None = None
86
+
87
+ async def __aenter__(self) -> Client:
88
+ try:
89
+ await self._connect()
90
+ except BaseException:
91
+ # A failed handshake such as a refused key closes what it opened since __aexit__ never runs
92
+ await self._stack.aclose()
93
+ raise
94
+ return self
95
+
96
+ async def _connect(self) -> None:
97
+ if self._url:
98
+ http = create_mcp_http_client(headers={"Authorization": f"Bearer {self._key}"} if self._key else None)
99
+ await self._stack.enter_async_context(http)
100
+ streams = await self._stack.enter_async_context(streamable_http_client(self._url, http_client=http))
101
+ else:
102
+ env = dict(os.environ)
103
+ if self._record_dir:
104
+ env["NODLOOP_RECORD_DIR"] = os.path.abspath(self._record_dir)
105
+ params = StdioServerParameters(command=self._binary or binary.find(VERSION), args=["mcp"], env=env)
106
+ streams = await self._stack.enter_async_context(stdio_client(params))
107
+ read, write = streams[0], streams[1]
108
+ self._session = await self._stack.enter_async_context(ClientSession(read, write))
109
+ await self._session.initialize()
110
+
111
+ async def __aexit__(self, *exc: object) -> None:
112
+ await self._stack.aclose()
113
+ self._session = None
114
+
115
+ async def call(self, name: str, arguments: dict[str, Any]) -> Any:
116
+ """The structured answer of one tool, raising ToolError when the tool refused"""
117
+ if self._session is None:
118
+ raise RuntimeError("use the client inside async with")
119
+ result = await self._session.call_tool(name, arguments)
120
+ text = "\n".join(getattr(c, "text", "") for c in result.content)
121
+ # mcp 2 names the fields in snake case and mcp 1 in camel case
122
+ if getattr(result, "is_error", None) or getattr(result, "isError", None):
123
+ raise ToolError(text)
124
+ structured = getattr(result, "structured_content", None) or getattr(result, "structuredContent", None)
125
+ if structured is not None:
126
+ return structured
127
+ return json.loads(text) if text else None
128
+
129
+ async def knowledge(self, producer: str, labels: dict[str, list[str]] | None = None) -> Knowledge:
130
+ """The approved items a run of the producer with the labels receives and the text to put in its prompt"""
131
+ answer = await self.call("knowledge_for", {"producer": producer, "labels": labels or {}})
132
+ items = [Item(i["id"], i["version"], i["kind"], i["content"], i.get("scope", {})) for i in answer.get("items") or []]
133
+ return Knowledge(items, answer.get("context", ""))
134
+
135
+ async def record(
136
+ self,
137
+ producer: str,
138
+ output: Any,
139
+ labels: dict[str, list[str]] | None = None,
140
+ applied: list[Item] | None = None,
141
+ subject: str = "",
142
+ ) -> str:
143
+ """Records one output and returns the run id a verdict cites"""
144
+ args: dict[str, Any] = {"producer": producer, "output": output, "labels": labels or {}}
145
+ if applied:
146
+ args["applied"] = [i.ref for i in applied]
147
+ if subject:
148
+ args["subject"] = subject
149
+ return (await self.call("run", args))["trace_id"]
150
+
151
+ async def judge(
152
+ self,
153
+ run: str,
154
+ verdict: str,
155
+ reason: str = "",
156
+ reason_code: str = "",
157
+ edited: Any = None,
158
+ reviewer: str = "",
159
+ ) -> None:
160
+ """Records a verdict on a run: approve, edit with the corrected output, reject with what was wrong, or withdraw an earlier one"""
161
+ args: dict[str, Any] = {"trace_id": run, "verdict": verdict}
162
+ for key, value in (("reason", reason), ("reason_code", reason_code), ("reviewer", reviewer)):
163
+ if value:
164
+ args[key] = value
165
+ if edited is not None:
166
+ args["edited_output"] = edited
167
+ await self.call("feedback", args)
168
+
169
+ async def propose(
170
+ self,
171
+ kind: str,
172
+ content: str,
173
+ from_run: str = "",
174
+ producer: str = "",
175
+ labels: dict[str, list[str]] | None = None,
176
+ new_labels: bool = False,
177
+ item_id: str = "",
178
+ veto: dict[str, Any] | None = None,
179
+ ) -> dict[str, Any]:
180
+ """Proposes a candidate a person approves later, from a corrected run or for a producer and labels
181
+
182
+ veto names a tool call the judgment forbids as tool, when and example, as the MCP tool propose takes it
183
+ """
184
+ args: dict[str, Any] = {"kind": kind, "content": content}
185
+ if veto:
186
+ args["veto"] = veto
187
+ for key, value in (("from", from_run), ("producer", producer), ("id", item_id)):
188
+ if value:
189
+ args[key] = value
190
+ if labels:
191
+ args["labels"] = labels
192
+ if new_labels:
193
+ args["new_labels"] = True
194
+ return await self.call("propose", args)
195
+
196
+ async def approve(self, item_id: str, version: int, approver: str) -> dict[str, Any]:
197
+ """Approves a candidate on behalf of the named person"""
198
+ return await self.call("approve", {"id": item_id, "version": version, "approver": approver})
199
+
200
+ async def check_call(self, tool: str, arguments: dict[str, Any], producer: str = "") -> Decision:
201
+ """Whether a veto approved for the producer blocks or asks about a tool call before the agent runs it
202
+
203
+ An empty producer checks against the vetoes of every producer
204
+ """
205
+ answer = await self.call("check_call", {"tool": tool, "input": arguments, "producer": producer})
206
+ return Decision(answer["action"], answer.get("veto", ""), answer.get("reason", ""))
nodloop/langchain.py ADDED
@@ -0,0 +1,62 @@
1
+ """LangChain agents middleware for nodloop
2
+
3
+ 1. every model call gets the approved items of the place in its system message
4
+ 2. the final answer of the agent is recorded as a run naming the items it got
5
+ 3. every tool call is checked against the approved vetoes and a blocked one answers the model with the reason
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from typing import Any
12
+
13
+ from langchain.agents.middleware import AgentMiddleware
14
+ from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
15
+
16
+ from .client import Client, Item
17
+
18
+ Labels = dict[str, list[str]]
19
+
20
+
21
+ class NodloopMiddleware(AgentMiddleware):
22
+ """Use one instance per agent run because it keeps the items and the run id of that run
23
+
24
+ labels is a mapping or a function of the agent state that returns one
25
+ """
26
+
27
+ def __init__(self, client: Client, producer: str, labels: Labels | Callable[[Any], Labels] | None = None) -> None:
28
+ super().__init__()
29
+ self.client = client
30
+ self.producer = producer
31
+ self.labels = labels or {}
32
+ self.applied: list[Item] = []
33
+ # The run the final answer was recorded as so a verdict can cite it
34
+ self.run: str = ""
35
+
36
+ def _labels(self, state: Any) -> Labels:
37
+ return self.labels(state) if callable(self.labels) else self.labels
38
+
39
+ async def awrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
40
+ knowledge = await self.client.knowledge(self.producer, self._labels(request.state))
41
+ self.applied = knowledge.items
42
+ if not knowledge.text:
43
+ return await handler(request)
44
+ base = request.system_message.content if request.system_message else ""
45
+ system = SystemMessage(content=f"{base}\n\n{knowledge.text}" if base else knowledge.text)
46
+ return await handler(request.override(system_message=system))
47
+
48
+ async def aafter_agent(self, state: Any, runtime: Any) -> None:
49
+ answers = [m for m in state["messages"] if isinstance(m, AIMessage)]
50
+ if answers:
51
+ self.run = await self.client.record(self.producer, answers[-1].text, self._labels(state), self.applied)
52
+
53
+ async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
54
+ call = request.tool_call
55
+ decision = await self.client.check_call(call["name"], call.get("args") or {}, self.producer)
56
+ if decision.allowed:
57
+ return await handler(request)
58
+ return ToolMessage(
59
+ content=f"nodloop veto {decision.veto} stopped this call: {decision.reason}",
60
+ tool_call_id=call["id"],
61
+ status="error",
62
+ )
@@ -0,0 +1,61 @@
1
+ """OpenAI Agents SDK hooks for nodloop
2
+
3
+ 1. instructions puts the approved items of the place after the instructions of the agent
4
+ 2. the hooks record the final output of the agent as a run naming the items it got
5
+ 3. veto_guardrail checks every tool call against the approved vetoes and rejects a blocked one with the reason
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from agents import RunHooks, ToolGuardrailFunctionOutput, tool_input_guardrail
15
+
16
+ from .client import Client, Item
17
+
18
+ Labels = dict[str, list[str]]
19
+
20
+
21
+ class NodloopHooks(RunHooks):
22
+ """Pass it as hooks to Runner.run and its instructions as the instructions of the agent
23
+
24
+ Use one instance per run because it keeps the items and the run id of that run
25
+ """
26
+
27
+ def __init__(self, client: Client, producer: str, labels: Labels | None = None) -> None:
28
+ self.client = client
29
+ self.producer = producer
30
+ self.labels = labels or {}
31
+ self.applied: list[Item] = []
32
+ # The run the final output was recorded as so a verdict can cite it
33
+ self.run: str = ""
34
+
35
+ def instructions(self, base: str) -> Callable[[Any, Any], Any]:
36
+ """Dynamic instructions: the base and the approved items under it"""
37
+
38
+ async def build(context: Any, agent: Any) -> str:
39
+ knowledge = await self.client.knowledge(self.producer, self.labels)
40
+ self.applied = knowledge.items
41
+ return f"{base}\n\n{knowledge.text}" if knowledge.text else base
42
+
43
+ return build
44
+
45
+ async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
46
+ text = output if isinstance(output, str) else json.dumps(output, default=str)
47
+ self.run = await self.client.record(self.producer, text, self.labels, self.applied)
48
+
49
+
50
+ def veto_guardrail(client: Client, producer: str = "") -> Any:
51
+ """A tool input guardrail for function_tool(tool_input_guardrails=[...]) checking the vetoes approved for the producer"""
52
+
53
+ @tool_input_guardrail
54
+ async def nodloop_veto(data: Any) -> ToolGuardrailFunctionOutput:
55
+ arguments = json.loads(data.context.tool_arguments or "{}")
56
+ decision = await client.check_call(data.context.tool_name, arguments, producer)
57
+ if decision.allowed:
58
+ return ToolGuardrailFunctionOutput.allow()
59
+ return ToolGuardrailFunctionOutput.reject_content(f"nodloop veto {decision.veto} stopped this call: {decision.reason}")
60
+
61
+ return nodloop_veto
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: nodloop
3
+ Version: 0.6.6
4
+ Summary: Record AI runs and the verdicts people give on them, and hand the knowledge they approve to the next run in the same place
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: mcp>=2.0
8
+ Provides-Extra: claude-agent
9
+ Requires-Dist: claude-agent-sdk>=0.1; extra == 'claude-agent'
10
+ Provides-Extra: langchain
11
+ Requires-Dist: langchain>=1.0; extra == 'langchain'
12
+ Provides-Extra: openai-agents
13
+ Requires-Dist: openai-agents>=0.3.3; extra == 'openai-agents'
@@ -0,0 +1,9 @@
1
+ nodloop/__init__.py,sha256=2PHpTd0LOK3-A9gMP8o92KVvMOVTztIr9PwGkozOi84,263
2
+ nodloop/binary.py,sha256=pUrwMmhTT8aqdnyICDY5ZOJ_uH4tItEDzi1pCtuUrT8,2311
3
+ nodloop/claude_agent.py,sha256=sGwJ_rtEXhGUknXBtKvgbIea57KEXOHXg7GeMRWF7YU,2705
4
+ nodloop/client.py,sha256=nEXdO3-haqUVQNTR6-nZnqAXXhdgVhMl9TWe4XU-j3o,7964
5
+ nodloop/langchain.py,sha256=i-qt7kojSPU8ItordDZty5XfRqQI45ZaUPukWbJRTkY,2647
6
+ nodloop/openai_agents.py,sha256=xuDLXV4yJJqAfEooZguHQSB4BPTk8qig-m8PXUUK2Vc,2497
7
+ nodloop-0.6.6.dist-info/METADATA,sha256=PmiJPjHcZ5uLaBxirdUquJAkZRYPz0JHMKi8ecpzWy0,516
8
+ nodloop-0.6.6.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ nodloop-0.6.6.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any