nodloop 0.8.0__tar.gz → 0.9.0__tar.gz
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-0.8.0 → nodloop-0.9.0}/.gitignore +1 -1
- {nodloop-0.8.0 → nodloop-0.9.0}/PKG-INFO +32 -4
- {nodloop-0.8.0 → nodloop-0.9.0}/README.md +31 -3
- {nodloop-0.8.0 → nodloop-0.9.0}/pyproject.toml +1 -1
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/__init__.py +2 -2
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/claude_agent.py +12 -5
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/client.py +83 -9
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/langchain.py +21 -5
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/openai_agents.py +10 -5
- {nodloop-0.8.0 → nodloop-0.9.0}/tests/test_adapters.py +19 -1
- {nodloop-0.8.0 → nodloop-0.9.0}/tests/test_client.py +61 -1
- {nodloop-0.8.0 → nodloop-0.9.0}/uv.lock +1 -1
- {nodloop-0.8.0 → nodloop-0.9.0}/src/nodloop/binary.py +0 -0
- {nodloop-0.8.0 → nodloop-0.9.0}/tests/conftest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nodloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
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
5
|
Project-URL: Homepage, https://github.com/jeon-jihyeon/nodloop
|
|
6
6
|
Project-URL: Repository, https://github.com/jeon-jihyeon/nodloop
|
|
@@ -50,15 +50,41 @@ From then on every `knowledge("support-bot", acme)` carries the item, and a run
|
|
|
50
50
|
|
|
51
51
|
| Method | Returns |
|
|
52
52
|
|---|---|
|
|
53
|
-
| `knowledge(producer, labels)` | `Knowledge` with `items
|
|
54
|
-
| `record(producer, output, *, labels, applied, subject)` | the run id a verdict cites |
|
|
53
|
+
| `knowledge(producer, labels, holdout_key)` | `Knowledge` with `items`, the `text` to put in the prompt and `withheld`. With a `holdout_key` such as `<service>:<hour>`, the `holdout` share of config.json gets no items and `withheld` instead |
|
|
54
|
+
| `record(producer, output, *, labels, applied, subject, withheld, holdout_key)` | the run id a verdict cites. Pass `knowledge.withheld` as `withheld` and the `holdout_key` the run passed to `knowledge`. `report effect` counts only runs recorded with the key and counts a run without it as `undrawn`. `withheld` takes `Ref` or `Item` values |
|
|
55
55
|
| `judge(run, verdict, reason, reason_code, edited, reviewer)` | nothing |
|
|
56
56
|
| `propose(kind, content, from_run, producer, labels, new_labels, item_id, veto)` | `Candidate` with `id`, `version`, `status` and `overlaps`, the current items of the same kind one run may carry with it |
|
|
57
57
|
| `approve(item_id, version, approver, labels=)` | Approves as proposed, or for the runs carrying `labels` when given, and `{}` for every run of the producer. `Approval` with `id`, `version`, `status`, `approver`, `veto`, `compaction_due` when one run would carry more than five items, and `export_error` and `folder_error` when a step after the approval failed |
|
|
58
58
|
| `check_call(tool, arguments, producer)` | `Decision` with `action` of allow, block or ask, `veto` and `reason` |
|
|
59
|
+
| `outcome(run, result, confirmed_cause, note, reviewer)` | nothing. `result` is a `Result`, confirmed, refuted or inconclusive: what a real check found, apart from the verdict on the output |
|
|
60
|
+
| `report(name)` | `Report` with `report`, the report as JSON, and `since`, the RFC3339 start of the runs it read or empty for every run. `name` is a `ReportName`: `loop`, `extract`, `critic`, `effect`, `health` or `replay` |
|
|
59
61
|
|
|
60
62
|
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
61
63
|
|
|
64
|
+
Every adapter takes an optional `holdout_key`, passes it to `knowledge` and `record` and records what came back as applied or withheld.
|
|
65
|
+
|
|
66
|
+
## A stream producer with a holdout
|
|
67
|
+
|
|
68
|
+
A producer that runs on a schedule, such as an anomaly check over each window of a stream, draws the holdout on a key it keeps for the run. Use `<service>:<time block of an hour or more>` so the windows of one block land in the same arm and a lesson seen in one window does not leak into a withheld window next to it.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from datetime import datetime, timezone
|
|
72
|
+
|
|
73
|
+
block = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") # one hour
|
|
74
|
+
orders = {"service": ["orders"]}
|
|
75
|
+
knowledge = await c.knowledge("lag-check", orders, holdout_key=f"orders:{block}")
|
|
76
|
+
answer = await check(window, knowledge.text)
|
|
77
|
+
run = await c.record(
|
|
78
|
+
"lag-check", answer, labels=orders, applied=knowledge.items, withheld=knowledge.withheld, holdout_key=f"orders:{block}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# later, when the cause was checked
|
|
82
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot")
|
|
83
|
+
effect = (await c.report("effect")).report
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The share comes from `holdout` in config.json. With `url` the share is the one in the config.json of the nodloop server, read when the server starts.
|
|
87
|
+
|
|
62
88
|
## Adapters
|
|
63
89
|
|
|
64
90
|
LangChain `create_agent` middleware fetches the items once per run and puts them in the system message, records the final answer and refuses a vetoed tool call:
|
|
@@ -89,12 +115,14 @@ Claude Agent SDK hooks, the same loop the Claude Code plugin runs:
|
|
|
89
115
|
```python
|
|
90
116
|
from nodloop.claude_agent import NodloopHooks
|
|
91
117
|
|
|
92
|
-
hooks = NodloopHooks(c, "support-bot", acme)
|
|
118
|
+
hooks = NodloopHooks(c, "support-bot", acme, holdout_key=conversation_id)
|
|
93
119
|
options = ClaudeAgentOptions(hooks=hooks.hooks())
|
|
94
120
|
```
|
|
95
121
|
|
|
96
122
|
Use one adapter instance per run or conversation, since it keeps the items it handed out and the id of the run it recorded.
|
|
97
123
|
|
|
124
|
+
The Claude Agent hooks draw once per conversation since the key stays the same for every turn, so a whole conversation is in one arm. `report effect` counts every turn of one key as one draw unit, so the turns of a conversation widen its intervals instead of passing for independent samples.
|
|
125
|
+
|
|
98
126
|
## A shared server
|
|
99
127
|
|
|
100
128
|
`Client(url="https://nodloop.example/mcp", key="nl_...")` connects to `nodloop-server serve` instead of a local binary. The key decides the tenant and what the client may do, and an approval through it is recorded under the key's name.
|
|
@@ -32,15 +32,41 @@ From then on every `knowledge("support-bot", acme)` carries the item, and a run
|
|
|
32
32
|
|
|
33
33
|
| Method | Returns |
|
|
34
34
|
|---|---|
|
|
35
|
-
| `knowledge(producer, labels)` | `Knowledge` with `items
|
|
36
|
-
| `record(producer, output, *, labels, applied, subject)` | the run id a verdict cites |
|
|
35
|
+
| `knowledge(producer, labels, holdout_key)` | `Knowledge` with `items`, the `text` to put in the prompt and `withheld`. With a `holdout_key` such as `<service>:<hour>`, the `holdout` share of config.json gets no items and `withheld` instead |
|
|
36
|
+
| `record(producer, output, *, labels, applied, subject, withheld, holdout_key)` | the run id a verdict cites. Pass `knowledge.withheld` as `withheld` and the `holdout_key` the run passed to `knowledge`. `report effect` counts only runs recorded with the key and counts a run without it as `undrawn`. `withheld` takes `Ref` or `Item` values |
|
|
37
37
|
| `judge(run, verdict, reason, reason_code, edited, reviewer)` | nothing |
|
|
38
38
|
| `propose(kind, content, from_run, producer, labels, new_labels, item_id, veto)` | `Candidate` with `id`, `version`, `status` and `overlaps`, the current items of the same kind one run may carry with it |
|
|
39
39
|
| `approve(item_id, version, approver, labels=)` | Approves as proposed, or for the runs carrying `labels` when given, and `{}` for every run of the producer. `Approval` with `id`, `version`, `status`, `approver`, `veto`, `compaction_due` when one run would carry more than five items, and `export_error` and `folder_error` when a step after the approval failed |
|
|
40
40
|
| `check_call(tool, arguments, producer)` | `Decision` with `action` of allow, block or ask, `veto` and `reason` |
|
|
41
|
+
| `outcome(run, result, confirmed_cause, note, reviewer)` | nothing. `result` is a `Result`, confirmed, refuted or inconclusive: what a real check found, apart from the verdict on the output |
|
|
42
|
+
| `report(name)` | `Report` with `report`, the report as JSON, and `since`, the RFC3339 start of the runs it read or empty for every run. `name` is a `ReportName`: `loop`, `extract`, `critic`, `effect`, `health` or `replay` |
|
|
41
43
|
|
|
42
44
|
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
43
45
|
|
|
46
|
+
Every adapter takes an optional `holdout_key`, passes it to `knowledge` and `record` and records what came back as applied or withheld.
|
|
47
|
+
|
|
48
|
+
## A stream producer with a holdout
|
|
49
|
+
|
|
50
|
+
A producer that runs on a schedule, such as an anomaly check over each window of a stream, draws the holdout on a key it keeps for the run. Use `<service>:<time block of an hour or more>` so the windows of one block land in the same arm and a lesson seen in one window does not leak into a withheld window next to it.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from datetime import datetime, timezone
|
|
54
|
+
|
|
55
|
+
block = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") # one hour
|
|
56
|
+
orders = {"service": ["orders"]}
|
|
57
|
+
knowledge = await c.knowledge("lag-check", orders, holdout_key=f"orders:{block}")
|
|
58
|
+
answer = await check(window, knowledge.text)
|
|
59
|
+
run = await c.record(
|
|
60
|
+
"lag-check", answer, labels=orders, applied=knowledge.items, withheld=knowledge.withheld, holdout_key=f"orders:{block}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# later, when the cause was checked
|
|
64
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot")
|
|
65
|
+
effect = (await c.report("effect")).report
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The share comes from `holdout` in config.json. With `url` the share is the one in the config.json of the nodloop server, read when the server starts.
|
|
69
|
+
|
|
44
70
|
## Adapters
|
|
45
71
|
|
|
46
72
|
LangChain `create_agent` middleware fetches the items once per run and puts them in the system message, records the final answer and refuses a vetoed tool call:
|
|
@@ -71,12 +97,14 @@ Claude Agent SDK hooks, the same loop the Claude Code plugin runs:
|
|
|
71
97
|
```python
|
|
72
98
|
from nodloop.claude_agent import NodloopHooks
|
|
73
99
|
|
|
74
|
-
hooks = NodloopHooks(c, "support-bot", acme)
|
|
100
|
+
hooks = NodloopHooks(c, "support-bot", acme, holdout_key=conversation_id)
|
|
75
101
|
options = ClaudeAgentOptions(hooks=hooks.hooks())
|
|
76
102
|
```
|
|
77
103
|
|
|
78
104
|
Use one adapter instance per run or conversation, since it keeps the items it handed out and the id of the run it recorded.
|
|
79
105
|
|
|
106
|
+
The Claude Agent hooks draw once per conversation since the key stays the same for every turn, so a whole conversation is in one arm. `report effect` counts every turn of one key as one draw unit, so the turns of a conversation widen its intervals instead of passing for independent samples.
|
|
107
|
+
|
|
80
108
|
## A shared server
|
|
81
109
|
|
|
82
110
|
`Client(url="https://nodloop.example/mcp", key="nl_...")` connects to `nodloop-server serve` instead of a local binary. The key decides the tenant and what the client may do, and an approval through it is recorded under the key's name.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
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
2
|
|
|
3
|
-
from .client import Approval, Candidate, Client, Decision, Item, Knowledge, ToolError
|
|
3
|
+
from .client import Approval, Candidate, Client, Decision, Item, Knowledge, Ref, Report, ReportName, Result, ToolError
|
|
4
4
|
|
|
5
|
-
__all__ = ["Approval", "Candidate", "Client", "Decision", "Item", "Knowledge", "ToolError"]
|
|
5
|
+
__all__ = ["Approval", "Candidate", "Client", "Decision", "Item", "Knowledge", "Ref", "Report", "ReportName", "Result", "ToolError"]
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from claude_agent_sdk import HookMatcher
|
|
13
13
|
|
|
14
|
-
from .client import Client, Item
|
|
14
|
+
from .client import Client, Item, Ref
|
|
15
15
|
|
|
16
16
|
Labels = dict[str, list[str]]
|
|
17
17
|
|
|
@@ -22,11 +22,16 @@ class NodloopHooks:
|
|
|
22
22
|
Use one instance per conversation because it keeps the items of the turn and the id of the last run
|
|
23
23
|
"""
|
|
24
24
|
|
|
25
|
-
def __init__(self, client: Client, producer: str, labels: Labels | None = None) -> None:
|
|
25
|
+
def __init__(self, client: Client, producer: str, labels: Labels | None = None, holdout_key: str = "") -> None:
|
|
26
26
|
self.client = client
|
|
27
27
|
self.producer = producer
|
|
28
28
|
self.labels = labels or {}
|
|
29
|
+
# Passed to knowledge so a share of conversations gets no item and report effect compares them
|
|
30
|
+
# The key stays the same for every turn so a whole conversation falls in one arm
|
|
31
|
+
# report effect counts the turns of one key as one draw unit so they weigh as a cluster and not as independent runs
|
|
32
|
+
self.holdout_key = holdout_key
|
|
29
33
|
self.applied: list[Item] = []
|
|
34
|
+
self.withheld: list[Ref] = []
|
|
30
35
|
# The run the last answer was recorded as so a verdict can cite it
|
|
31
36
|
self.run: str = ""
|
|
32
37
|
|
|
@@ -38,8 +43,8 @@ class NodloopHooks:
|
|
|
38
43
|
}
|
|
39
44
|
|
|
40
45
|
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
|
|
46
|
+
knowledge = await self.client.knowledge(self.producer, self.labels, self.holdout_key)
|
|
47
|
+
self.applied, self.withheld = knowledge.items, knowledge.withheld
|
|
43
48
|
if not knowledge.text:
|
|
44
49
|
return {}
|
|
45
50
|
return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": knowledge.text}}
|
|
@@ -49,7 +54,9 @@ class NodloopHooks:
|
|
|
49
54
|
# A turn without one records nothing
|
|
50
55
|
answer = input_data.get("last_assistant_message") or ""
|
|
51
56
|
if answer.strip():
|
|
52
|
-
self.run = await self.client.record(
|
|
57
|
+
self.run = await self.client.record(
|
|
58
|
+
self.producer, answer, labels=self.labels, applied=self.applied, withheld=self.withheld, holdout_key=self.holdout_key
|
|
59
|
+
)
|
|
53
60
|
return {}
|
|
54
61
|
|
|
55
62
|
async def pre_tool_use(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
@@ -6,7 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
from contextlib import AsyncExitStack
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any
|
|
9
|
+
from typing import Any, Literal
|
|
10
10
|
|
|
11
11
|
from mcp import ClientSession, StdioServerParameters
|
|
12
12
|
from mcp.client.stdio import stdio_client
|
|
@@ -14,16 +14,31 @@ from mcp.client.streamable_http import create_mcp_http_client, streamable_http_c
|
|
|
14
14
|
|
|
15
15
|
from . import binary
|
|
16
16
|
|
|
17
|
-
__all__ = ["Approval", "Candidate", "Client", "Decision", "Item", "Knowledge", "ToolError"]
|
|
17
|
+
__all__ = ["Approval", "Candidate", "Client", "Decision", "Item", "Knowledge", "Ref", "Report", "ReportName", "Result", "ToolError"]
|
|
18
18
|
|
|
19
19
|
# The binary release the client fetches when no nodloop is installed
|
|
20
|
-
VERSION = "0.
|
|
20
|
+
VERSION = "0.9.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# The reports report reads
|
|
24
|
+
ReportName = Literal["loop", "extract", "critic", "effect", "health", "replay"]
|
|
25
|
+
|
|
26
|
+
# What a real check found for a run
|
|
27
|
+
Result = Literal["confirmed", "refuted", "inconclusive"]
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
class ToolError(RuntimeError):
|
|
24
31
|
"""A tool answered an error such as a refused proposal"""
|
|
25
32
|
|
|
26
33
|
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Ref:
|
|
36
|
+
"""One knowledge version as a run names it"""
|
|
37
|
+
|
|
38
|
+
id: str
|
|
39
|
+
version: int
|
|
40
|
+
|
|
41
|
+
|
|
27
42
|
@dataclass(frozen=True)
|
|
28
43
|
class Item:
|
|
29
44
|
"""One approved knowledge version a run receives"""
|
|
@@ -89,6 +104,27 @@ class Knowledge:
|
|
|
89
104
|
items: list[Item]
|
|
90
105
|
# Empty without items so a prompt gains nothing
|
|
91
106
|
text: str
|
|
107
|
+
# The id and version of each item the holdout kept from the run
|
|
108
|
+
# Empty unless the holdout drew the key
|
|
109
|
+
# Pass them to record as withheld so the run counts in the withheld arm of report effect
|
|
110
|
+
withheld: list[Ref] = field(default_factory=list)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Report:
|
|
115
|
+
"""One report of the records with the start of the runs it read"""
|
|
116
|
+
|
|
117
|
+
# The report as JSON
|
|
118
|
+
report: Any
|
|
119
|
+
# The RFC3339 start of the window and empty when the report read every run
|
|
120
|
+
since: str = ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _ref(v: Item | Ref | dict[str, Any]) -> dict[str, Any]:
|
|
124
|
+
"""The id and version the run tool takes from an item or a ref"""
|
|
125
|
+
if isinstance(v, dict):
|
|
126
|
+
return {"id": v["id"], "version": v["version"]}
|
|
127
|
+
return {"id": v.id, "version": v.version}
|
|
92
128
|
|
|
93
129
|
|
|
94
130
|
class Client:
|
|
@@ -156,11 +192,20 @@ class Client:
|
|
|
156
192
|
return structured
|
|
157
193
|
return json.loads(text) if text else None
|
|
158
194
|
|
|
159
|
-
async def knowledge(self, producer: str, labels: dict[str, list[str]] | None = None) -> Knowledge:
|
|
160
|
-
"""The approved items a run of the producer with the labels receives and the text to put in its prompt
|
|
161
|
-
|
|
195
|
+
async def knowledge(self, producer: str, labels: dict[str, list[str]] | None = None, holdout_key: str = "") -> Knowledge:
|
|
196
|
+
"""The approved items a run of the producer with the labels receives and the text to put in its prompt
|
|
197
|
+
|
|
198
|
+
holdout_key is a string kept for one run such as a service and an hour block and the holdout share of config.json draws on it
|
|
199
|
+
A drawn run gets no item and withheld instead
|
|
200
|
+
Without a key nothing is withheld
|
|
201
|
+
"""
|
|
202
|
+
args: dict[str, Any] = {"producer": producer, "labels": labels or {}}
|
|
203
|
+
if holdout_key:
|
|
204
|
+
args["holdout_key"] = holdout_key
|
|
205
|
+
answer = await self.call("knowledge_for", args)
|
|
162
206
|
items = [Item(i["id"], i["version"], i["kind"], i["content"], i.get("scope", {})) for i in answer.get("items") or []]
|
|
163
|
-
|
|
207
|
+
withheld = [Ref(r["id"], r["version"]) for r in answer.get("withheld") or []]
|
|
208
|
+
return Knowledge(items, answer.get("context", ""), withheld)
|
|
164
209
|
|
|
165
210
|
async def record(
|
|
166
211
|
self,
|
|
@@ -170,13 +215,25 @@ class Client:
|
|
|
170
215
|
labels: dict[str, list[str]] | None = None,
|
|
171
216
|
applied: list[Item] | None = None,
|
|
172
217
|
subject: str = "",
|
|
218
|
+
withheld: list[Item | Ref] | None = None,
|
|
219
|
+
holdout_key: str = "",
|
|
173
220
|
) -> str:
|
|
174
|
-
"""Records one output and returns the run id a verdict cites
|
|
221
|
+
"""Records one output and returns the run id a verdict cites
|
|
222
|
+
|
|
223
|
+
withheld takes the refs knowledge answered for a run the holdout drew or the items themselves
|
|
224
|
+
Never together with applied
|
|
225
|
+
holdout_key is the key the run passed to knowledge
|
|
226
|
+
report effect counts a run without it only as undrawn
|
|
227
|
+
"""
|
|
175
228
|
args: dict[str, Any] = {"producer": producer, "output": output, "labels": labels or {}}
|
|
176
229
|
if applied:
|
|
177
|
-
args["applied"] = [i
|
|
230
|
+
args["applied"] = [_ref(i) for i in applied]
|
|
231
|
+
if withheld:
|
|
232
|
+
args["withheld"] = [_ref(r) for r in withheld]
|
|
178
233
|
if subject:
|
|
179
234
|
args["subject"] = subject
|
|
235
|
+
if holdout_key:
|
|
236
|
+
args["holdout_key"] = holdout_key
|
|
180
237
|
return (await self.call("run", args))["trace_id"]
|
|
181
238
|
|
|
182
239
|
async def judge(
|
|
@@ -197,6 +254,23 @@ class Client:
|
|
|
197
254
|
args["edited_output"] = edited
|
|
198
255
|
await self.call("feedback", args)
|
|
199
256
|
|
|
257
|
+
async def outcome(self, run: str, result: Result, confirmed_cause: str = "", note: str = "", reviewer: str = "") -> None:
|
|
258
|
+
"""Records what a real check found for a run: confirmed, refuted or inconclusive
|
|
259
|
+
|
|
260
|
+
A different fact from the verdict on the output
|
|
261
|
+
confirmed_cause only goes with confirmed
|
|
262
|
+
"""
|
|
263
|
+
args: dict[str, Any] = {"trace_id": run, "result": result}
|
|
264
|
+
for key, value in (("confirmed_cause", confirmed_cause), ("note", note), ("reviewer", reviewer)):
|
|
265
|
+
if value:
|
|
266
|
+
args[key] = value
|
|
267
|
+
await self.call("outcome", args)
|
|
268
|
+
|
|
269
|
+
async def report(self, name: ReportName) -> Report:
|
|
270
|
+
"""One report of the records as JSON with the start of the window it read"""
|
|
271
|
+
answer = await self.call("report", {"name": name})
|
|
272
|
+
return Report(answer["report"], answer.get("since", ""))
|
|
273
|
+
|
|
200
274
|
async def propose(
|
|
201
275
|
self,
|
|
202
276
|
kind: str,
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
from langchain.agents.middleware import AgentMiddleware
|
|
14
14
|
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
|
15
15
|
|
|
16
|
-
from .client import Client, Item, Knowledge
|
|
16
|
+
from .client import Client, Item, Knowledge, Ref
|
|
17
17
|
|
|
18
18
|
Labels = dict[str, list[str]]
|
|
19
19
|
|
|
@@ -24,12 +24,21 @@ class NodloopMiddleware(AgentMiddleware):
|
|
|
24
24
|
labels is a mapping or a function of the agent state that returns one
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
def __init__(
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
client: Client,
|
|
30
|
+
producer: str,
|
|
31
|
+
labels: Labels | Callable[[Any], Labels] | None = None,
|
|
32
|
+
holdout_key: str = "",
|
|
33
|
+
) -> None:
|
|
28
34
|
super().__init__()
|
|
29
35
|
self.client = client
|
|
30
36
|
self.producer = producer
|
|
31
37
|
self.labels = labels or {}
|
|
38
|
+
# Passed to knowledge so a share of runs gets no item and report effect compares them
|
|
39
|
+
self.holdout_key = holdout_key
|
|
32
40
|
self.applied: list[Item] = []
|
|
41
|
+
self.withheld: list[Ref] = []
|
|
33
42
|
# The items of the current run, fetched on its first model call and dropped once the run is recorded
|
|
34
43
|
self._knowledge: Knowledge | None = None
|
|
35
44
|
# The run the final answer was recorded as so a verdict can cite it
|
|
@@ -40,8 +49,8 @@ class NodloopMiddleware(AgentMiddleware):
|
|
|
40
49
|
|
|
41
50
|
async def awrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
42
51
|
if self._knowledge is None:
|
|
43
|
-
self._knowledge = await self.client.knowledge(self.producer, self._labels(request.state))
|
|
44
|
-
self.applied = self._knowledge.items
|
|
52
|
+
self._knowledge = await self.client.knowledge(self.producer, self._labels(request.state), self.holdout_key)
|
|
53
|
+
self.applied, self.withheld = self._knowledge.items, self._knowledge.withheld
|
|
45
54
|
text = self._knowledge.text
|
|
46
55
|
if not text:
|
|
47
56
|
return await handler(request)
|
|
@@ -50,7 +59,14 @@ class NodloopMiddleware(AgentMiddleware):
|
|
|
50
59
|
async def aafter_agent(self, state: Any, runtime: Any) -> None:
|
|
51
60
|
answers = [m for m in state["messages"] if isinstance(m, AIMessage)]
|
|
52
61
|
if answers:
|
|
53
|
-
self.run = await self.client.record(
|
|
62
|
+
self.run = await self.client.record(
|
|
63
|
+
self.producer,
|
|
64
|
+
answers[-1].text,
|
|
65
|
+
labels=self._labels(state),
|
|
66
|
+
applied=self.applied,
|
|
67
|
+
withheld=self.withheld,
|
|
68
|
+
holdout_key=self.holdout_key,
|
|
69
|
+
)
|
|
54
70
|
self._knowledge = None
|
|
55
71
|
|
|
56
72
|
async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
@@ -13,7 +13,7 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from agents import RunHooks, ToolGuardrailFunctionOutput, tool_input_guardrail
|
|
15
15
|
|
|
16
|
-
from .client import Client, Item
|
|
16
|
+
from .client import Client, Item, Ref
|
|
17
17
|
|
|
18
18
|
Labels = dict[str, list[str]]
|
|
19
19
|
|
|
@@ -24,11 +24,14 @@ class NodloopHooks(RunHooks):
|
|
|
24
24
|
Use one instance per run because it keeps the items and the run id of that run
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
def __init__(self, client: Client, producer: str, labels: Labels | None = None) -> None:
|
|
27
|
+
def __init__(self, client: Client, producer: str, labels: Labels | None = None, holdout_key: str = "") -> None:
|
|
28
28
|
self.client = client
|
|
29
29
|
self.producer = producer
|
|
30
30
|
self.labels = labels or {}
|
|
31
|
+
# Passed to knowledge so a share of runs gets no item and report effect compares them
|
|
32
|
+
self.holdout_key = holdout_key
|
|
31
33
|
self.applied: list[Item] = []
|
|
34
|
+
self.withheld: list[Ref] = []
|
|
32
35
|
# The run the final output was recorded as so a verdict can cite it
|
|
33
36
|
self.run: str = ""
|
|
34
37
|
|
|
@@ -36,15 +39,17 @@ class NodloopHooks(RunHooks):
|
|
|
36
39
|
"""Dynamic instructions: the base and the approved items under it"""
|
|
37
40
|
|
|
38
41
|
async def build(context: Any, agent: Any) -> str:
|
|
39
|
-
knowledge = await self.client.knowledge(self.producer, self.labels)
|
|
40
|
-
self.applied = knowledge.items
|
|
42
|
+
knowledge = await self.client.knowledge(self.producer, self.labels, self.holdout_key)
|
|
43
|
+
self.applied, self.withheld = knowledge.items, knowledge.withheld
|
|
41
44
|
return f"{base}\n\n{knowledge.text}" if knowledge.text else base
|
|
42
45
|
|
|
43
46
|
return build
|
|
44
47
|
|
|
45
48
|
async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
|
|
46
49
|
text = output if isinstance(output, str) else json.dumps(output, default=str)
|
|
47
|
-
self.run = await self.client.record(
|
|
50
|
+
self.run = await self.client.record(
|
|
51
|
+
self.producer, text, labels=self.labels, applied=self.applied, withheld=self.withheld, holdout_key=self.holdout_key
|
|
52
|
+
)
|
|
48
53
|
|
|
49
54
|
|
|
50
55
|
def veto_guardrail(client: Client, producer: str = "") -> Any:
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
from dataclasses import dataclass, field, replace
|
|
2
|
+
from pathlib import Path
|
|
2
3
|
from types import SimpleNamespace
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
7
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
7
8
|
|
|
8
|
-
from nodloop import Client
|
|
9
|
+
from nodloop import Client, Ref
|
|
9
10
|
from nodloop.claude_agent import NodloopHooks as ClaudeHooks
|
|
10
11
|
from nodloop.langchain import NodloopMiddleware
|
|
11
12
|
from nodloop.openai_agents import NodloopHooks as OpenAIHooks
|
|
@@ -159,3 +160,20 @@ async def test_langchain_create_agent(binary: str, records: str) -> None:
|
|
|
159
160
|
assert mw.run in recorded
|
|
160
161
|
assert len(prompts) == 2 and all(p.startswith("You help\n\nnodloop: corrections") for p in prompts)
|
|
161
162
|
assert [i.id for i in mw.applied] == ["window"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def test_adapter_records_withheld(binary: str, records: str, home: Path) -> None:
|
|
166
|
+
"""An adapter with a holdout key the holdout draws gets no item and records the run as withheld"""
|
|
167
|
+
(home / ".nodloop").mkdir(exist_ok=True)
|
|
168
|
+
(home / ".nodloop" / "config.json").write_text('{"holdout": 0.999}')
|
|
169
|
+
async with Client(records, binary) as c:
|
|
170
|
+
await seed(c)
|
|
171
|
+
hooks = ClaudeHooks(c, "bot", ACME, holdout_key="conversation-1")
|
|
172
|
+
|
|
173
|
+
prompt = await hooks.prompt({"prompt": "refund?"}, None, None)
|
|
174
|
+
await hooks.stop({"last_assistant_message": "The window is 30 days"}, None, None)
|
|
175
|
+
effect = (await c.call("report", {"name": "effect"}))["report"]
|
|
176
|
+
|
|
177
|
+
assert prompt == {}
|
|
178
|
+
assert hooks.applied == [] and Ref("window", 1) in hooks.withheld
|
|
179
|
+
assert [(r["runs"], r["judged"], r["undrawn"]) for r in effect if r.get("producer") == "bot" and r["arm"] == "withheld"] == [(1, 0, 0)]
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
1
4
|
import pytest
|
|
2
5
|
from mcp.shared.exceptions import MCPError
|
|
3
6
|
|
|
4
|
-
from nodloop import Client, ToolError
|
|
7
|
+
from nodloop import Client, Ref, ToolError
|
|
5
8
|
|
|
6
9
|
ACME = {"tenant": ["acme"], "task": ["refund"]}
|
|
7
10
|
NO_RM = {
|
|
@@ -109,3 +112,60 @@ async def test_approve_in_picked_labels(binary: str, records: str) -> None:
|
|
|
109
112
|
|
|
110
113
|
assert approved.status == "approved"
|
|
111
114
|
assert [i.id for i in globex.items] == [proposed.id]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def test_holdout_key_withholds_and_record_joins_the_withheld_arm(binary: str, records: str, home: Path) -> None:
|
|
118
|
+
(home / ".nodloop").mkdir(exist_ok=True)
|
|
119
|
+
(home / ".nodloop" / "config.json").write_text('{"holdout": 0.5}')
|
|
120
|
+
async with Client(records, binary) as c:
|
|
121
|
+
run = await c.record("stream-bot", "lag rose", labels=ACME)
|
|
122
|
+
await c.judge(run, "reject", reason="name the partition")
|
|
123
|
+
proposed = await c.propose("judgment", "Name the partition", from_run=run, item_id="partition")
|
|
124
|
+
await c.approve(proposed.id, proposed.version, "ann")
|
|
125
|
+
drawn = [await c.knowledge("stream-bot", ACME, holdout_key=f"window-{n}") for n in range(200)]
|
|
126
|
+
unkeyed = await c.knowledge("stream-bot", ACME)
|
|
127
|
+
n, withheld = next((n, k) for n, k in enumerate(drawn) if k.withheld)
|
|
128
|
+
await c.record("stream-bot", "lag rose again", labels=ACME, withheld=withheld.withheld, holdout_key=f"window-{n}")
|
|
129
|
+
effect = (await c.call("report", {"name": "effect"}))["report"]
|
|
130
|
+
|
|
131
|
+
assert 60 <= sum(1 for k in drawn if k.withheld) <= 140
|
|
132
|
+
assert all((k.items == [] and k.text == "") for k in drawn if k.withheld)
|
|
133
|
+
assert withheld.withheld == [Ref("partition", 1)]
|
|
134
|
+
assert unkeyed.withheld == [] and [i.id for i in unkeyed.items] == ["partition"]
|
|
135
|
+
rows = [r for r in effect if r.get("producer") == "stream-bot" and r["arm"] == "withheld"]
|
|
136
|
+
assert [(r["runs"], r["judged"], r["undrawn"]) for r in rows] == [(1, 0, 0)]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def test_record_takes_items_as_withheld(binary: str, records: str) -> None:
|
|
140
|
+
"""Items a producer passes as withheld are recorded by id and version like applied ones"""
|
|
141
|
+
async with Client(records, binary) as c:
|
|
142
|
+
run = await c.record("stream-bot", "lag rose", labels=ACME)
|
|
143
|
+
await c.judge(run, "reject", reason="name the partition")
|
|
144
|
+
proposed = await c.propose("judgment", "Name the partition", from_run=run, item_id="partition")
|
|
145
|
+
await c.approve(proposed.id, proposed.version, "ann")
|
|
146
|
+
items = (await c.knowledge("stream-bot", ACME)).items
|
|
147
|
+
|
|
148
|
+
recorded = await c.record("stream-bot", "lag rose again", labels=ACME, withheld=items)
|
|
149
|
+
effect = (await c.report("effect")).report
|
|
150
|
+
|
|
151
|
+
runs = [json.loads(line) for line in (Path(records) / "traces.jsonl").read_text().splitlines() if line.strip()]
|
|
152
|
+
run_input = next(t["input"] for t in runs if t["id"] == recorded)
|
|
153
|
+
assert run_input["applied"] == [] and run_input["withheld"] == [{"id": "partition", "version": 1}]
|
|
154
|
+
# Recorded without a holdout key the run counts in no arm and only as undrawn
|
|
155
|
+
assert [(r["runs"], r["undrawn"]) for r in effect if r.get("producer") == "stream-bot"] == [(0, 1), (0, 1)]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_outcome_and_report(binary: str, records: str) -> None:
|
|
159
|
+
# A prune another surface ran sets the window every report reads
|
|
160
|
+
Path(records).mkdir()
|
|
161
|
+
(Path(records) / "prune.json").write_text('{"at":"2020-01-02T00:00:00Z","cutoff":"2020-01-01T00:00:00Z"}')
|
|
162
|
+
async with Client(records, binary) as c:
|
|
163
|
+
run = await c.record("stream-bot", "lag comes from partition 7", labels=ACME)
|
|
164
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot", note="checked the broker")
|
|
165
|
+
health = await c.report("health")
|
|
166
|
+
loop = await c.report("loop")
|
|
167
|
+
with pytest.raises(ToolError):
|
|
168
|
+
await c.outcome(run, "refuted", confirmed_cause="partition 7 was hot")
|
|
169
|
+
|
|
170
|
+
assert isinstance(health.report, (list, dict)) and loop.report["totals"]["runs"] == 1
|
|
171
|
+
assert loop.since == "2020-01-01T00:00:00Z"
|
|
File without changes
|
|
File without changes
|