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 +5 -0
- nodloop/binary.py +67 -0
- nodloop/claude_agent.py +65 -0
- nodloop/client.py +206 -0
- nodloop/langchain.py +62 -0
- nodloop/openai_agents.py +61 -0
- nodloop-0.6.6.dist-info/METADATA +13 -0
- nodloop-0.6.6.dist-info/RECORD +9 -0
- nodloop-0.6.6.dist-info/WHEEL +4 -0
nodloop/__init__.py
ADDED
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
|
nodloop/claude_agent.py
ADDED
|
@@ -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
|
+
)
|
nodloop/openai_agents.py
ADDED
|
@@ -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,,
|