nodloop 0.7.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.
@@ -17,7 +17,7 @@ __pycache__/
17
17
  /.claude/nodloop/
18
18
  .claude/settings.local.json
19
19
  CLAUDE.local.md
20
- /tmp
20
+ /tmp/
21
21
 
22
22
  # environment
23
23
  .env*
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nodloop
3
- Version: 0.7.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` and the `text` to put in the prompt |
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
- | `approve(item_id, version, approver)` | `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 |
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` and the `text` to put in the prompt |
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
- | `approve(item_id, version, approver)` | `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 |
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodloop"
3
- version = "0.7.0"
3
+ version = "0.9.0"
4
4
  description = "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
  readme = "README.md"
6
6
  license = "MIT"
@@ -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(self.producer, answer, labels=self.labels, applied=self.applied)
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.7.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
- answer = await self.call("knowledge_for", {"producer": producer, "labels": labels or {}})
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
- return Knowledge(items, answer.get("context", ""))
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.ref for i in applied]
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,
@@ -225,9 +299,15 @@ class Client:
225
299
  answer = await self.call("propose", args)
226
300
  return Candidate(answer["id"], answer["version"], answer["status"], answer.get("overlaps") or [])
227
301
 
228
- async def approve(self, item_id: str, version: int, approver: str) -> Approval:
229
- """Approves a candidate on behalf of the named person"""
230
- answer = await self.call("approve", {"id": item_id, "version": version, "approver": approver})
302
+ async def approve(self, item_id: str, version: int, approver: str, *, labels: dict[str, list[str]] | None = None) -> Approval:
303
+ """Approves a candidate on behalf of the named person
304
+
305
+ labels approves it for the runs carrying them instead of the proposed scope and an empty mapping for every run of its producer
306
+ """
307
+ args: dict[str, Any] = {"id": item_id, "version": version, "approver": approver}
308
+ if labels is not None:
309
+ args["scope"] = {"labels": labels}
310
+ answer = await self.call("approve", args)
231
311
  return Approval(
232
312
  answer["id"],
233
313
  answer["version"],
@@ -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__(self, client: Client, producer: str, labels: Labels | Callable[[Any], Labels] | None = None) -> None:
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(self.producer, answers[-1].text, labels=self._labels(state), applied=self.applied)
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(self.producer, text, labels=self.labels, applied=self.applied)
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 = {
@@ -96,3 +99,73 @@ async def test_approval_says_a_compaction_is_due(binary: str, records: str) -> N
96
99
 
97
100
  assert [a.compaction_due for a in approvals] == [False] * 5 + [True]
98
101
  assert all(a.folder_error == "" for a in approvals)
102
+
103
+
104
+ async def test_approve_in_picked_labels(binary: str, records: str) -> None:
105
+ async with Client(records, binary) as c:
106
+ run = await c.record("support-bot", "Here are the refund steps", labels=ACME)
107
+ await c.record("support-bot", "ok", labels={"tenant": ["globex"]})
108
+ await c.judge(run, "reject", reason="too long", reason_code="form")
109
+ proposed = await c.propose("judgment", "Answer in three lines", from_run=run)
110
+ approved = await c.approve(proposed.id, proposed.version, "ann", labels={})
111
+ globex = await c.knowledge("support-bot", {"tenant": ["globex"]})
112
+
113
+ assert approved.status == "approved"
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"
@@ -337,7 +337,7 @@ wheels = [
337
337
 
338
338
  [[package]]
339
339
  name = "claude-agent-sdk"
340
- version = "0.2.161"
340
+ version = "0.2.162"
341
341
  source = { registry = "https://pypi.org/simple" }
342
342
  dependencies = [
343
343
  { name = "anyio" },
@@ -346,12 +346,12 @@ dependencies = [
346
346
  { name = "sniffio" },
347
347
  { name = "typing-extensions", marker = "python_full_version < '3.11'" },
348
348
  ]
349
- sdist = { url = "https://files.pythonhosted.org/packages/8b/e1/8c94ff7f200eabcee9a9e57825dfac596bb6fbdeeafa594f49bc7e7f89b5/claude_agent_sdk-0.2.161.tar.gz", hash = "sha256:284a1372ec3af133578009ab2b821a238272dbf17d687a58be9df918d7b3ae89", size = 365168, upload-time = "2026-09-28T18:53:39.197Z" }
349
+ sdist = { url = "https://files.pythonhosted.org/packages/78/a3/d31886d0723593228797c96eb54d07a19e561931a2771ed40b4a105bedc9/claude_agent_sdk-0.2.162.tar.gz", hash = "sha256:e4cf92f04f934d3b87d42be9ecbeb75c1a3d487a8dc8a299f7fed1ad9d0b476f", size = 365167, upload-time = "2026-09-29T20:06:54.593Z" }
350
350
  wheels = [
351
- { url = "https://files.pythonhosted.org/packages/e8/be/528e3ec2bb30deff4fcc9484dd47d1022786ad3659d31110bd0ea66ea548/claude_agent_sdk-0.2.161-py3-none-macosx_11_0_arm64.whl", hash = "sha256:127c18a790dc9f77cc2d648f932e11b06cd3325c4b3de7a73d3bcf3d3b74b8bc", size = 93872213, upload-time = "2026-09-28T18:53:42.956Z" },
352
- { url = "https://files.pythonhosted.org/packages/17/1c/a0dcb628ac2f25bc92d858857a399202723098057828a909ca4f9893b721/claude_agent_sdk-0.2.161-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:599e1c8ccb4d48ed57999b48e80937d6a6aefbc52b0c0cda1291aea21df29e3e", size = 98316495, upload-time = "2026-09-28T18:53:47.239Z" },
353
- { url = "https://files.pythonhosted.org/packages/73/04/3c0caee46c27eee75642b52ee11857c0d3b9d6917ecfd916da2ffc2c2d3f/claude_agent_sdk-0.2.161-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:18081d3277c07a71891bd15d1496387d9efc5f04a6b482ae3a03e7a0b8067069", size = 103331951, upload-time = "2026-09-28T18:53:51.367Z" },
354
- { url = "https://files.pythonhosted.org/packages/09/70/2c7c4160a19c6edd42e317bdc9cf810e91b6a535c04377fb09d4ee090769/claude_agent_sdk-0.2.161-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:b854e46d2889a51e1a77978711a05c757e63064bd5d8f89bcf74bb507babfc2c", size = 103684983, upload-time = "2026-09-28T18:53:55.496Z" },
351
+ { url = "https://files.pythonhosted.org/packages/6d/b6/ec37e32cb0782977e80cd00338f5073f150d6ecfa568e9fa1a1dd5c9eed3/claude_agent_sdk-0.2.162-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a0d5ad6185a8bcaaff05f5648036e2484cab28bd1c2ccc474fc8969555b386f7", size = 92700771, upload-time = "2026-09-29T20:06:59.661Z" },
352
+ { url = "https://files.pythonhosted.org/packages/92/c4/1165688243cbf90d3532f509ea65bc337c3ba7f8229e26d7297e00a6ec2b/claude_agent_sdk-0.2.162-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:faab97b03f04f9c3deb58ad85a0410404d40b6efb5547c9defc4fa986642fced", size = 97130408, upload-time = "2026-09-29T20:07:06.243Z" },
353
+ { url = "https://files.pythonhosted.org/packages/05/41/f0addb831c3aefbc02c811ef7415e89e889d1bc2ae976eb935c508c91c0d/claude_agent_sdk-0.2.162-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:332cfb5eb9190fc8de1ad427753417d7c345264e9d2bd2ffa312afcc2f5cf766", size = 102183342, upload-time = "2026-09-29T20:07:14.279Z" },
354
+ { url = "https://files.pythonhosted.org/packages/d2/33/8a7acd75405d0cfde96c26219dbbac050aa23416ceefd5f62cf7aecfc22d/claude_agent_sdk-0.2.162-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:673598c9916f5f4d03ff49ab3128f856f0c6508135e8e258838fc3b6f7aafa49", size = 102525631, upload-time = "2026-09-29T20:07:23.411Z" },
355
355
  ]
356
356
 
357
357
  [[package]]
@@ -899,7 +899,7 @@ wheels = [
899
899
 
900
900
  [[package]]
901
901
  name = "nodloop"
902
- version = "0.7.0"
902
+ version = "0.9.0"
903
903
  source = { editable = "." }
904
904
  dependencies = [
905
905
  { name = "mcp" },
File without changes
File without changes