nodloop 0.8.0__tar.gz → 0.10.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.10.0}/.gitignore +1 -1
- nodloop-0.10.0/PKG-INFO +131 -0
- nodloop-0.10.0/README.md +113 -0
- {nodloop-0.8.0 → nodloop-0.10.0}/pyproject.toml +1 -1
- nodloop-0.10.0/src/nodloop/__init__.py +38 -0
- {nodloop-0.8.0 → nodloop-0.10.0}/src/nodloop/claude_agent.py +22 -5
- nodloop-0.10.0/src/nodloop/client.py +466 -0
- {nodloop-0.8.0 → nodloop-0.10.0}/src/nodloop/langchain.py +26 -5
- {nodloop-0.8.0 → nodloop-0.10.0}/src/nodloop/openai_agents.py +20 -5
- nodloop-0.10.0/src/nodloop/records.py +1538 -0
- nodloop-0.10.0/tests/conftest.py +112 -0
- {nodloop-0.8.0 → nodloop-0.10.0}/tests/test_adapters.py +43 -15
- nodloop-0.10.0/tests/test_client.py +380 -0
- {nodloop-0.8.0 → nodloop-0.10.0}/uv.lock +17 -16
- nodloop-0.8.0/PKG-INFO +0 -104
- nodloop-0.8.0/README.md +0 -86
- nodloop-0.8.0/src/nodloop/__init__.py +0 -5
- nodloop-0.8.0/src/nodloop/client.py +0 -254
- nodloop-0.8.0/tests/conftest.py +0 -61
- nodloop-0.8.0/tests/test_client.py +0 -111
- {nodloop-0.8.0 → nodloop-0.10.0}/src/nodloop/binary.py +0 -0
nodloop-0.10.0/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: nodloop
|
|
3
|
+
Version: 0.10.0
|
|
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
|
+
Project-URL: Homepage, https://github.com/jeon-jihyeon/nodloop
|
|
6
|
+
Project-URL: Repository, https://github.com/jeon-jihyeon/nodloop
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: agents,ai,feedback,human-in-the-loop,langchain,mcp,memory
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: mcp>=2.0
|
|
11
|
+
Provides-Extra: claude-agent
|
|
12
|
+
Requires-Dist: claude-agent-sdk>=0.1; extra == 'claude-agent'
|
|
13
|
+
Provides-Extra: langchain
|
|
14
|
+
Requires-Dist: langchain>=1.0; extra == 'langchain'
|
|
15
|
+
Provides-Extra: openai-agents
|
|
16
|
+
Requires-Dist: openai-agents>=0.3.3; extra == 'openai-agents'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# nodloop for Python
|
|
20
|
+
|
|
21
|
+
Record what your agent answered, take a person's verdict on it, and hand the corrections they approve to the agent's next run in the same situation. This is the Python client of [nodloop](https://github.com/jeon-jihyeon/nodloop).
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
pip install nodloop
|
|
25
|
+
pip install "nodloop[langchain]" # or openai-agents, claude-agent
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The client starts a local `nodloop mcp` over stdio, so no server runs. It uses `NODLOOP_BIN`, then the binary the Claude Code plugin keeps under `~/.nodloop/bin`, then `nodloop` on PATH, and otherwise downloads the release of its own version from GitHub and checks it against the release checksums. It requires Python 3.10 or newer and is tested on Linux with Python 3.12.
|
|
29
|
+
|
|
30
|
+
## The loop
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from nodloop import Client
|
|
34
|
+
|
|
35
|
+
acme = {"tenant": ["acme"], "task": ["refund"]}
|
|
36
|
+
|
|
37
|
+
async with Client() as c:
|
|
38
|
+
knowledge = await c.knowledge("support-bot", acme)
|
|
39
|
+
prompt = f"{system_prompt}\n\n{knowledge.text}" # empty text when nothing is approved
|
|
40
|
+
answer = await my_agent(prompt)
|
|
41
|
+
run = await c.record("support-bot", answer, labels=acme, applied=knowledge.items)
|
|
42
|
+
|
|
43
|
+
# later, when a person reads the answer
|
|
44
|
+
await c.judge(run, "reject", "ann", reason="the refund window was missing", reason_code="scope")
|
|
45
|
+
candidate = await c.propose("judgment", "Quote the refund window before the steps", from_run=run)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The person approves the candidate in a terminal of their own with `nodloop knowledge approve <id> --version <n>`, or the client approves it through a nodloop server under an approver key the person holds, added with `--person`. Over stdio `approve` records a proposal and changes nothing, since a program proves no person. From then on every `knowledge("support-bot", acme)` carries the item, and a run of another tenant never does. `judge` takes `approve`, `edit` with `edited=` holding the corrected output, `reject` with what was wrong, or `withdraw` to take back the verdict before it, and always the name of the reviewer.
|
|
49
|
+
|
|
50
|
+
| Method | Returns |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `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 |
|
|
53
|
+
| `record(producer, output, *, labels, applied, subject, withheld, holdout_key, model)` | 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. `model` names the one model the producer runs, which the OTLP export sends as `gen_ai.request.model`. Leave it out when the producer may switch models |
|
|
54
|
+
| `judge(run, verdict, reviewer, reason, reason_code, edited)` | nothing. `reviewer` is required, so a verdict without a name never reads as a person's |
|
|
55
|
+
| `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 |
|
|
56
|
+
| `approve(item_id, version, approver, labels=)` | Under a key of a nodloop server that a person holds, approves as proposed, or for the runs carrying `labels` when given, and `{}` for every run of the producer. Anywhere else it records a proposal the person is asked about and the status stays. `Approval` with `id`, `version`, `status`, `approver`, `veto`, `proposal` when a proposal was recorded, and `export_error` and `folder_error` when a step after the approval failed |
|
|
57
|
+
| `check_call(tool, arguments, producer)` | `Decision` with `action` of allow, block or ask, `veto` and `reason` |
|
|
58
|
+
| `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. Over stdio `reviewer` is required |
|
|
59
|
+
| `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`. `critic` is `{critics, reactions}`: the rows of the draft critics and of the reaction classifiers |
|
|
60
|
+
|
|
61
|
+
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
62
|
+
|
|
63
|
+
Every adapter takes an optional `holdout_key`, passes it to `knowledge` and `record` and records what came back as applied or withheld.
|
|
64
|
+
|
|
65
|
+
## A stream producer with a holdout
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from datetime import datetime, timezone
|
|
71
|
+
|
|
72
|
+
block = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") # one hour
|
|
73
|
+
orders = {"service": ["orders"]}
|
|
74
|
+
knowledge = await c.knowledge("lag-check", orders, holdout_key=f"orders:{block}")
|
|
75
|
+
answer = await check(window, knowledge.text)
|
|
76
|
+
run = await c.record(
|
|
77
|
+
"lag-check", answer, labels=orders, applied=knowledge.items, withheld=knowledge.withheld, holdout_key=f"orders:{block}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# later, when the cause was checked
|
|
81
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot", reviewer="ann")
|
|
82
|
+
effect = (await c.report("effect")).report
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
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.
|
|
86
|
+
|
|
87
|
+
## Adapters
|
|
88
|
+
|
|
89
|
+
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:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from nodloop.langchain import NodloopMiddleware
|
|
93
|
+
|
|
94
|
+
mw = NodloopMiddleware(c, "support-bot", acme)
|
|
95
|
+
agent = create_agent(model, tools, middleware=[mw])
|
|
96
|
+
await agent.ainvoke({"messages": [...]})
|
|
97
|
+
await c.judge(mw.run, "approve", "ann")
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
OpenAI Agents SDK hooks and a tool input guardrail:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from nodloop.openai_agents import NodloopHooks, veto_guardrail
|
|
104
|
+
|
|
105
|
+
hooks = NodloopHooks(c, "support-bot", acme)
|
|
106
|
+
agent = Agent(name="support", instructions=hooks.instructions("You help with refunds"), tools=[...])
|
|
107
|
+
await Runner.run(agent, "refund?", hooks=hooks)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Pass `veto_guardrail(c, "support-bot")` in a tool's `tool_input_guardrails`.
|
|
111
|
+
|
|
112
|
+
Claude Agent SDK hooks, the same loop the Claude Code plugin runs:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from nodloop.claude_agent import NodloopHooks
|
|
116
|
+
|
|
117
|
+
hooks = NodloopHooks(c, "support-bot", acme, holdout_key=conversation_id)
|
|
118
|
+
options = ClaudeAgentOptions(hooks=hooks.hooks())
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use one adapter instance per run or conversation, since it keeps the items it handed out and the id of the run it recorded.
|
|
122
|
+
|
|
123
|
+
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.
|
|
124
|
+
|
|
125
|
+
## A shared server
|
|
126
|
+
|
|
127
|
+
`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.
|
|
128
|
+
|
|
129
|
+
## More
|
|
130
|
+
|
|
131
|
+
The [nodloop README](https://github.com/jeon-jihyeon/nodloop#readme) covers labels, vetoes, reports and the server. MIT licensed.
|
nodloop-0.10.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# nodloop for Python
|
|
2
|
+
|
|
3
|
+
Record what your agent answered, take a person's verdict on it, and hand the corrections they approve to the agent's next run in the same situation. This is the Python client of [nodloop](https://github.com/jeon-jihyeon/nodloop).
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
pip install nodloop
|
|
7
|
+
pip install "nodloop[langchain]" # or openai-agents, claude-agent
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The client starts a local `nodloop mcp` over stdio, so no server runs. It uses `NODLOOP_BIN`, then the binary the Claude Code plugin keeps under `~/.nodloop/bin`, then `nodloop` on PATH, and otherwise downloads the release of its own version from GitHub and checks it against the release checksums. It requires Python 3.10 or newer and is tested on Linux with Python 3.12.
|
|
11
|
+
|
|
12
|
+
## The loop
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from nodloop import Client
|
|
16
|
+
|
|
17
|
+
acme = {"tenant": ["acme"], "task": ["refund"]}
|
|
18
|
+
|
|
19
|
+
async with Client() as c:
|
|
20
|
+
knowledge = await c.knowledge("support-bot", acme)
|
|
21
|
+
prompt = f"{system_prompt}\n\n{knowledge.text}" # empty text when nothing is approved
|
|
22
|
+
answer = await my_agent(prompt)
|
|
23
|
+
run = await c.record("support-bot", answer, labels=acme, applied=knowledge.items)
|
|
24
|
+
|
|
25
|
+
# later, when a person reads the answer
|
|
26
|
+
await c.judge(run, "reject", "ann", reason="the refund window was missing", reason_code="scope")
|
|
27
|
+
candidate = await c.propose("judgment", "Quote the refund window before the steps", from_run=run)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The person approves the candidate in a terminal of their own with `nodloop knowledge approve <id> --version <n>`, or the client approves it through a nodloop server under an approver key the person holds, added with `--person`. Over stdio `approve` records a proposal and changes nothing, since a program proves no person. From then on every `knowledge("support-bot", acme)` carries the item, and a run of another tenant never does. `judge` takes `approve`, `edit` with `edited=` holding the corrected output, `reject` with what was wrong, or `withdraw` to take back the verdict before it, and always the name of the reviewer.
|
|
31
|
+
|
|
32
|
+
| Method | Returns |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `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 |
|
|
35
|
+
| `record(producer, output, *, labels, applied, subject, withheld, holdout_key, model)` | 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. `model` names the one model the producer runs, which the OTLP export sends as `gen_ai.request.model`. Leave it out when the producer may switch models |
|
|
36
|
+
| `judge(run, verdict, reviewer, reason, reason_code, edited)` | nothing. `reviewer` is required, so a verdict without a name never reads as a person's |
|
|
37
|
+
| `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 |
|
|
38
|
+
| `approve(item_id, version, approver, labels=)` | Under a key of a nodloop server that a person holds, approves as proposed, or for the runs carrying `labels` when given, and `{}` for every run of the producer. Anywhere else it records a proposal the person is asked about and the status stays. `Approval` with `id`, `version`, `status`, `approver`, `veto`, `proposal` when a proposal was recorded, and `export_error` and `folder_error` when a step after the approval failed |
|
|
39
|
+
| `check_call(tool, arguments, producer)` | `Decision` with `action` of allow, block or ask, `veto` and `reason` |
|
|
40
|
+
| `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. Over stdio `reviewer` is required |
|
|
41
|
+
| `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`. `critic` is `{critics, reactions}`: the rows of the draft critics and of the reaction classifiers |
|
|
42
|
+
|
|
43
|
+
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
44
|
+
|
|
45
|
+
Every adapter takes an optional `holdout_key`, passes it to `knowledge` and `record` and records what came back as applied or withheld.
|
|
46
|
+
|
|
47
|
+
## A stream producer with a holdout
|
|
48
|
+
|
|
49
|
+
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.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from datetime import datetime, timezone
|
|
53
|
+
|
|
54
|
+
block = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H") # one hour
|
|
55
|
+
orders = {"service": ["orders"]}
|
|
56
|
+
knowledge = await c.knowledge("lag-check", orders, holdout_key=f"orders:{block}")
|
|
57
|
+
answer = await check(window, knowledge.text)
|
|
58
|
+
run = await c.record(
|
|
59
|
+
"lag-check", answer, labels=orders, applied=knowledge.items, withheld=knowledge.withheld, holdout_key=f"orders:{block}"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# later, when the cause was checked
|
|
63
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot", reviewer="ann")
|
|
64
|
+
effect = (await c.report("effect")).report
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
## Adapters
|
|
70
|
+
|
|
71
|
+
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:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from nodloop.langchain import NodloopMiddleware
|
|
75
|
+
|
|
76
|
+
mw = NodloopMiddleware(c, "support-bot", acme)
|
|
77
|
+
agent = create_agent(model, tools, middleware=[mw])
|
|
78
|
+
await agent.ainvoke({"messages": [...]})
|
|
79
|
+
await c.judge(mw.run, "approve", "ann")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
OpenAI Agents SDK hooks and a tool input guardrail:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from nodloop.openai_agents import NodloopHooks, veto_guardrail
|
|
86
|
+
|
|
87
|
+
hooks = NodloopHooks(c, "support-bot", acme)
|
|
88
|
+
agent = Agent(name="support", instructions=hooks.instructions("You help with refunds"), tools=[...])
|
|
89
|
+
await Runner.run(agent, "refund?", hooks=hooks)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pass `veto_guardrail(c, "support-bot")` in a tool's `tool_input_guardrails`.
|
|
93
|
+
|
|
94
|
+
Claude Agent SDK hooks, the same loop the Claude Code plugin runs:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from nodloop.claude_agent import NodloopHooks
|
|
98
|
+
|
|
99
|
+
hooks = NodloopHooks(c, "support-bot", acme, holdout_key=conversation_id)
|
|
100
|
+
options = ClaudeAgentOptions(hooks=hooks.hooks())
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Use one adapter instance per run or conversation, since it keeps the items it handed out and the id of the run it recorded.
|
|
104
|
+
|
|
105
|
+
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.
|
|
106
|
+
|
|
107
|
+
## A shared server
|
|
108
|
+
|
|
109
|
+
`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.
|
|
110
|
+
|
|
111
|
+
## More
|
|
112
|
+
|
|
113
|
+
The [nodloop README](https://github.com/jeon-jihyeon/nodloop#readme) covers labels, vetoes, reports and the server. MIT licensed.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Record AI runs and the verdicts people give on them, and hand the knowledge they approve to the next run in the same place"""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
Approval,
|
|
5
|
+
Candidate,
|
|
6
|
+
Client,
|
|
7
|
+
Decision,
|
|
8
|
+
Intervention,
|
|
9
|
+
InterventionAction,
|
|
10
|
+
Item,
|
|
11
|
+
Knowledge,
|
|
12
|
+
Page,
|
|
13
|
+
Ref,
|
|
14
|
+
Report,
|
|
15
|
+
ReportName,
|
|
16
|
+
Result,
|
|
17
|
+
ToolError,
|
|
18
|
+
)
|
|
19
|
+
from .records import AnyRecord, RecordKind
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AnyRecord",
|
|
23
|
+
"Approval",
|
|
24
|
+
"Candidate",
|
|
25
|
+
"Client",
|
|
26
|
+
"Decision",
|
|
27
|
+
"Intervention",
|
|
28
|
+
"InterventionAction",
|
|
29
|
+
"Item",
|
|
30
|
+
"Knowledge",
|
|
31
|
+
"Page",
|
|
32
|
+
"RecordKind",
|
|
33
|
+
"Ref",
|
|
34
|
+
"Report",
|
|
35
|
+
"ReportName",
|
|
36
|
+
"Result",
|
|
37
|
+
"ToolError",
|
|
38
|
+
]
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import time
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from claude_agent_sdk import HookMatcher
|
|
13
14
|
|
|
14
|
-
from .client import Client, Item
|
|
15
|
+
from .client import Client, Item, Ref
|
|
15
16
|
|
|
16
17
|
Labels = dict[str, list[str]]
|
|
17
18
|
|
|
@@ -22,13 +23,20 @@ class NodloopHooks:
|
|
|
22
23
|
Use one instance per conversation because it keeps the items of the turn and the id of the last run
|
|
23
24
|
"""
|
|
24
25
|
|
|
25
|
-
def __init__(self, client: Client, producer: str, labels: Labels | None = None) -> None:
|
|
26
|
+
def __init__(self, client: Client, producer: str, labels: Labels | None = None, holdout_key: str = "") -> None:
|
|
26
27
|
self.client = client
|
|
27
28
|
self.producer = producer
|
|
28
29
|
self.labels = labels or {}
|
|
30
|
+
# Passed to knowledge so a share of conversations gets no item and report effect compares them
|
|
31
|
+
# The key stays the same for every turn so a whole conversation falls in one arm
|
|
32
|
+
# report effect counts the turns of one key as one draw unit so they weigh as a cluster and not as independent runs
|
|
33
|
+
self.holdout_key = holdout_key
|
|
29
34
|
self.applied: list[Item] = []
|
|
35
|
+
self.withheld: list[Ref] = []
|
|
30
36
|
# The run the last answer was recorded as so a verdict can cite it
|
|
31
37
|
self.run: str = ""
|
|
38
|
+
# When the prompt of the turn came so the run records how long the turn took
|
|
39
|
+
self._started = time.monotonic()
|
|
32
40
|
|
|
33
41
|
def hooks(self) -> dict[str, list[HookMatcher]]:
|
|
34
42
|
return {
|
|
@@ -38,8 +46,9 @@ class NodloopHooks:
|
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
async def prompt(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
self._started = time.monotonic()
|
|
50
|
+
knowledge = await self.client.knowledge(self.producer, self.labels, self.holdout_key)
|
|
51
|
+
self.applied, self.withheld = knowledge.items, knowledge.withheld
|
|
43
52
|
if not knowledge.text:
|
|
44
53
|
return {}
|
|
45
54
|
return {"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": knowledge.text}}
|
|
@@ -49,7 +58,15 @@ class NodloopHooks:
|
|
|
49
58
|
# A turn without one records nothing
|
|
50
59
|
answer = input_data.get("last_assistant_message") or ""
|
|
51
60
|
if answer.strip():
|
|
52
|
-
self.run = await self.client.record(
|
|
61
|
+
self.run = await self.client.record(
|
|
62
|
+
self.producer,
|
|
63
|
+
answer,
|
|
64
|
+
labels=self.labels,
|
|
65
|
+
applied=self.applied,
|
|
66
|
+
withheld=self.withheld,
|
|
67
|
+
holdout_key=self.holdout_key,
|
|
68
|
+
duration_ms=int((time.monotonic() - self._started) * 1000),
|
|
69
|
+
)
|
|
53
70
|
return {}
|
|
54
71
|
|
|
55
72
|
async def pre_tool_use(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
|