nodloop 0.9.0__tar.gz → 0.10.1__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.9.0 → nodloop-0.10.1}/PKG-INFO +10 -11
- {nodloop-0.9.0 → nodloop-0.10.1}/README.md +9 -10
- {nodloop-0.9.0 → nodloop-0.10.1}/pyproject.toml +1 -1
- nodloop-0.10.1/src/nodloop/__init__.py +38 -0
- {nodloop-0.9.0 → nodloop-0.10.1}/src/nodloop/claude_agent.py +11 -1
- {nodloop-0.9.0 → nodloop-0.10.1}/src/nodloop/client.py +164 -15
- {nodloop-0.9.0 → nodloop-0.10.1}/src/nodloop/langchain.py +5 -0
- {nodloop-0.9.0 → nodloop-0.10.1}/src/nodloop/openai_agents.py +11 -1
- nodloop-0.10.1/src/nodloop/records.py +1593 -0
- nodloop-0.10.1/tests/conftest.py +112 -0
- {nodloop-0.9.0 → nodloop-0.10.1}/tests/test_adapters.py +26 -16
- nodloop-0.10.1/tests/test_client.py +385 -0
- {nodloop-0.9.0 → nodloop-0.10.1}/uv.lock +17 -16
- nodloop-0.9.0/src/nodloop/__init__.py +0 -5
- nodloop-0.9.0/tests/conftest.py +0 -61
- nodloop-0.9.0/tests/test_client.py +0 -171
- {nodloop-0.9.0 → nodloop-0.10.1}/.gitignore +0 -0
- {nodloop-0.9.0 → nodloop-0.10.1}/src/nodloop/binary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: nodloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.1
|
|
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
|
|
@@ -41,23 +41,22 @@ async with Client() as c:
|
|
|
41
41
|
run = await c.record("support-bot", answer, labels=acme, applied=knowledge.items)
|
|
42
42
|
|
|
43
43
|
# later, when a person reads the answer
|
|
44
|
-
await c.judge(run, "reject", reason="the refund window was missing", reason_code="scope")
|
|
44
|
+
await c.judge(run, "reject", "ann", reason="the refund window was missing", reason_code="scope")
|
|
45
45
|
candidate = await c.propose("judgment", "Quote the refund window before the steps", from_run=run)
|
|
46
|
-
approval = await c.approve(candidate.id, candidate.version, approver="ann")
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
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.
|
|
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.
|
|
50
49
|
|
|
51
50
|
| Method | Returns |
|
|
52
51
|
|---|---|
|
|
53
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 |
|
|
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
|
-
| `judge(run, verdict, reason, reason_code, edited
|
|
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 |
|
|
56
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 |
|
|
57
|
-
| `approve(item_id, version, approver, labels=)` |
|
|
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 |
|
|
58
57
|
| `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` |
|
|
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 |
|
|
61
60
|
|
|
62
61
|
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
63
62
|
|
|
@@ -79,7 +78,7 @@ run = await c.record(
|
|
|
79
78
|
)
|
|
80
79
|
|
|
81
80
|
# later, when the cause was checked
|
|
82
|
-
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot")
|
|
81
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot", reviewer="ann")
|
|
83
82
|
effect = (await c.report("effect")).report
|
|
84
83
|
```
|
|
85
84
|
|
|
@@ -95,7 +94,7 @@ from nodloop.langchain import NodloopMiddleware
|
|
|
95
94
|
mw = NodloopMiddleware(c, "support-bot", acme)
|
|
96
95
|
agent = create_agent(model, tools, middleware=[mw])
|
|
97
96
|
await agent.ainvoke({"messages": [...]})
|
|
98
|
-
await c.judge(mw.run, "approve")
|
|
97
|
+
await c.judge(mw.run, "approve", "ann")
|
|
99
98
|
```
|
|
100
99
|
|
|
101
100
|
OpenAI Agents SDK hooks and a tool input guardrail:
|
|
@@ -23,23 +23,22 @@ async with Client() as c:
|
|
|
23
23
|
run = await c.record("support-bot", answer, labels=acme, applied=knowledge.items)
|
|
24
24
|
|
|
25
25
|
# later, when a person reads the answer
|
|
26
|
-
await c.judge(run, "reject", reason="the refund window was missing", reason_code="scope")
|
|
26
|
+
await c.judge(run, "reject", "ann", reason="the refund window was missing", reason_code="scope")
|
|
27
27
|
candidate = await c.propose("judgment", "Quote the refund window before the steps", from_run=run)
|
|
28
|
-
approval = await c.approve(candidate.id, candidate.version, approver="ann")
|
|
29
28
|
```
|
|
30
29
|
|
|
31
|
-
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.
|
|
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.
|
|
32
31
|
|
|
33
32
|
| Method | Returns |
|
|
34
33
|
|---|---|
|
|
35
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 |
|
|
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
|
-
| `judge(run, verdict, reason, reason_code, edited
|
|
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 |
|
|
38
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 |
|
|
39
|
-
| `approve(item_id, version, approver, labels=)` |
|
|
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 |
|
|
40
39
|
| `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` |
|
|
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 |
|
|
43
42
|
|
|
44
43
|
A tool that refuses, such as a proposal from a run nobody corrected, raises `ToolError` with the reason.
|
|
45
44
|
|
|
@@ -61,7 +60,7 @@ run = await c.record(
|
|
|
61
60
|
)
|
|
62
61
|
|
|
63
62
|
# later, when the cause was checked
|
|
64
|
-
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot")
|
|
63
|
+
await c.outcome(run, "confirmed", confirmed_cause="partition 7 was hot", reviewer="ann")
|
|
65
64
|
effect = (await c.report("effect")).report
|
|
66
65
|
```
|
|
67
66
|
|
|
@@ -77,7 +76,7 @@ from nodloop.langchain import NodloopMiddleware
|
|
|
77
76
|
mw = NodloopMiddleware(c, "support-bot", acme)
|
|
78
77
|
agent = create_agent(model, tools, middleware=[mw])
|
|
79
78
|
await agent.ainvoke({"messages": [...]})
|
|
80
|
-
await c.judge(mw.run, "approve")
|
|
79
|
+
await c.judge(mw.run, "approve", "ann")
|
|
81
80
|
```
|
|
82
81
|
|
|
83
82
|
OpenAI Agents SDK hooks and a tool input guardrail:
|
|
@@ -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,6 +7,7 @@
|
|
|
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
|
|
@@ -34,6 +35,8 @@ class NodloopHooks:
|
|
|
34
35
|
self.withheld: list[Ref] = []
|
|
35
36
|
# The run the last answer was recorded as so a verdict can cite it
|
|
36
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()
|
|
37
40
|
|
|
38
41
|
def hooks(self) -> dict[str, list[HookMatcher]]:
|
|
39
42
|
return {
|
|
@@ -43,6 +46,7 @@ class NodloopHooks:
|
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
async def prompt(self, input_data: dict[str, Any], tool_use_id: str | None, context: Any) -> dict[str, Any]:
|
|
49
|
+
self._started = time.monotonic()
|
|
46
50
|
knowledge = await self.client.knowledge(self.producer, self.labels, self.holdout_key)
|
|
47
51
|
self.applied, self.withheld = knowledge.items, knowledge.withheld
|
|
48
52
|
if not knowledge.text:
|
|
@@ -55,7 +59,13 @@ class NodloopHooks:
|
|
|
55
59
|
answer = input_data.get("last_assistant_message") or ""
|
|
56
60
|
if answer.strip():
|
|
57
61
|
self.run = await self.client.record(
|
|
58
|
-
self.producer,
|
|
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),
|
|
59
69
|
)
|
|
60
70
|
return {}
|
|
61
71
|
|
|
@@ -13,16 +13,37 @@ from mcp.client.stdio import stdio_client
|
|
|
13
13
|
from mcp.client.streamable_http import create_mcp_http_client, streamable_http_client
|
|
14
14
|
|
|
15
15
|
from . import binary
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
from .records import AnyRecord, InterventionRecord, RecordKind
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Approval",
|
|
20
|
+
"Candidate",
|
|
21
|
+
"Client",
|
|
22
|
+
"Decision",
|
|
23
|
+
"Intervention",
|
|
24
|
+
"InterventionAction",
|
|
25
|
+
"Item",
|
|
26
|
+
"Knowledge",
|
|
27
|
+
"Page",
|
|
28
|
+
"Ref",
|
|
29
|
+
"Report",
|
|
30
|
+
"ReportName",
|
|
31
|
+
"Result",
|
|
32
|
+
"ToolError",
|
|
33
|
+
]
|
|
18
34
|
|
|
19
35
|
# The binary release the client fetches when no nodloop is installed
|
|
20
|
-
VERSION = "0.
|
|
36
|
+
VERSION = "0.10.1"
|
|
21
37
|
|
|
22
38
|
|
|
23
39
|
# The reports report reads
|
|
40
|
+
# Deprecated: page the raw records with records instead since they carry the published contract
|
|
24
41
|
ReportName = Literal["loop", "extract", "critic", "effect", "health", "replay"]
|
|
25
42
|
|
|
43
|
+
# The actions of the write side
|
|
44
|
+
# A test checks them against the generated record type of an intervention
|
|
45
|
+
InterventionAction = Literal["judge", "answer", "dismiss", "pause", "resume", "knob", "reanchor", "digest", "reverse"]
|
|
46
|
+
|
|
26
47
|
# What a real check found for a run
|
|
27
48
|
Result = Literal["confirmed", "refuted", "inconclusive"]
|
|
28
49
|
|
|
@@ -80,21 +101,22 @@ class Candidate:
|
|
|
80
101
|
|
|
81
102
|
@dataclass(frozen=True)
|
|
82
103
|
class Approval:
|
|
83
|
-
"""An approved knowledge version"""
|
|
104
|
+
"""An approved knowledge version or the agent proposal of a call that proves no person while the version stays as it was"""
|
|
84
105
|
|
|
85
106
|
id: str
|
|
86
107
|
version: int
|
|
87
108
|
status: str
|
|
88
109
|
# The approver the records keep, which a server sets to the key's name
|
|
89
110
|
approver: str
|
|
90
|
-
# Whether the
|
|
111
|
+
# Whether the version carries a veto the guard applies once approved
|
|
91
112
|
veto: bool = False
|
|
92
|
-
# Whether one run would carry more than five items so a compaction is due
|
|
93
|
-
compaction_due: bool = False
|
|
94
113
|
# Why approved.md or the veto file could not be written, while the approval itself stands
|
|
95
114
|
export_error: str = ""
|
|
96
115
|
# Why the items one run carries with it could not be read, while the approval itself stands
|
|
97
116
|
folder_error: str = ""
|
|
117
|
+
# The intervention recorded as an agent proposal when the call proved no person
|
|
118
|
+
# Empty when it approved
|
|
119
|
+
proposal: str = ""
|
|
98
120
|
|
|
99
121
|
|
|
100
122
|
@dataclass(frozen=True)
|
|
@@ -112,7 +134,10 @@ class Knowledge:
|
|
|
112
134
|
|
|
113
135
|
@dataclass(frozen=True)
|
|
114
136
|
class Report:
|
|
115
|
-
"""One report of the records with the start of the runs it read
|
|
137
|
+
"""One report of the records with the start of the runs it read
|
|
138
|
+
|
|
139
|
+
Deprecated: page the raw records with records instead since they carry the published contract
|
|
140
|
+
"""
|
|
116
141
|
|
|
117
142
|
# The report as JSON
|
|
118
143
|
report: Any
|
|
@@ -120,6 +145,39 @@ class Report:
|
|
|
120
145
|
since: str = ""
|
|
121
146
|
|
|
122
147
|
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class Page:
|
|
150
|
+
"""One page of raw records in the published schema of their kind, in the order they were appended"""
|
|
151
|
+
|
|
152
|
+
# Each record as the TypedDict of its kind in nodloop.records
|
|
153
|
+
records: list[AnyRecord]
|
|
154
|
+
# The cursor to pass as since for the next page
|
|
155
|
+
# A page with nothing new still answers one to pass again
|
|
156
|
+
next: str = ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Intervention:
|
|
161
|
+
"""What one intervention of the write side recorded"""
|
|
162
|
+
|
|
163
|
+
# The intervention record in its published schema and None when the person declined the form
|
|
164
|
+
intervention: InterventionRecord | None
|
|
165
|
+
# person when a key a person holds decided and agent for a proposal the person is asked about
|
|
166
|
+
authority: str = ""
|
|
167
|
+
proof: str = ""
|
|
168
|
+
# The knowledge records it wrote, each with id, version and status
|
|
169
|
+
records: list[dict[str, Any]] = field(default_factory=list)
|
|
170
|
+
# The answer event an answer wrote
|
|
171
|
+
answer: str = ""
|
|
172
|
+
# The cursor of a digest to pass as since to the records of answer, intervention, decide, expose and knowledge
|
|
173
|
+
since: str = ""
|
|
174
|
+
# The decide that asks the person about a proposal
|
|
175
|
+
question: str = ""
|
|
176
|
+
# The person declined the form so nothing was recorded
|
|
177
|
+
declined: bool = False
|
|
178
|
+
export_error: str = ""
|
|
179
|
+
|
|
180
|
+
|
|
123
181
|
def _ref(v: Item | Ref | dict[str, Any]) -> dict[str, Any]:
|
|
124
182
|
"""The id and version the run tool takes from an item or a ref"""
|
|
125
183
|
if isinstance(v, dict):
|
|
@@ -198,6 +256,7 @@ class Client:
|
|
|
198
256
|
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
257
|
A drawn run gets no item and withheld instead
|
|
200
258
|
Without a key nothing is withheld
|
|
259
|
+
the correlation label may name an external conversation without becoming inferred knowledge scope
|
|
201
260
|
"""
|
|
202
261
|
args: dict[str, Any] = {"producer": producer, "labels": labels or {}}
|
|
203
262
|
if holdout_key:
|
|
@@ -217,6 +276,8 @@ class Client:
|
|
|
217
276
|
subject: str = "",
|
|
218
277
|
withheld: list[Item | Ref] | None = None,
|
|
219
278
|
holdout_key: str = "",
|
|
279
|
+
model: str = "",
|
|
280
|
+
duration_ms: int = 0,
|
|
220
281
|
) -> str:
|
|
221
282
|
"""Records one output and returns the run id a verdict cites
|
|
222
283
|
|
|
@@ -224,6 +285,10 @@ class Client:
|
|
|
224
285
|
Never together with applied
|
|
225
286
|
holdout_key is the key the run passed to knowledge
|
|
226
287
|
report effect counts a run without it only as undrawn
|
|
288
|
+
the correlation label preserves an external id independently of the returned run id and holdout_key
|
|
289
|
+
model is the one model the producer runs and the OTLP export names it
|
|
290
|
+
Leave it out when the producer may switch models
|
|
291
|
+
duration_ms is how long the run took up to this call so its OTLP span has a length
|
|
227
292
|
"""
|
|
228
293
|
args: dict[str, Any] = {"producer": producer, "output": output, "labels": labels or {}}
|
|
229
294
|
if applied:
|
|
@@ -234,18 +299,29 @@ class Client:
|
|
|
234
299
|
args["subject"] = subject
|
|
235
300
|
if holdout_key:
|
|
236
301
|
args["holdout_key"] = holdout_key
|
|
302
|
+
if model:
|
|
303
|
+
args["model"] = model
|
|
304
|
+
if duration_ms:
|
|
305
|
+
args["duration_ms"] = duration_ms
|
|
237
306
|
return (await self.call("run", args))["trace_id"]
|
|
238
307
|
|
|
239
308
|
async def judge(
|
|
240
309
|
self,
|
|
241
310
|
run: str,
|
|
242
311
|
verdict: str,
|
|
312
|
+
*,
|
|
313
|
+
reviewer: str,
|
|
243
314
|
reason: str = "",
|
|
244
315
|
reason_code: str = "",
|
|
245
316
|
edited: Any = None,
|
|
246
|
-
reviewer: str = "",
|
|
247
317
|
) -> None:
|
|
248
|
-
"""Records a verdict on a run: approve, edit with the corrected output, reject with what was wrong, or withdraw an earlier one
|
|
318
|
+
"""Records a verdict on a run: approve, edit with the corrected output, reject with what was wrong, or withdraw an earlier one
|
|
319
|
+
|
|
320
|
+
reviewer names who judged so a verdict without a name never reads as a person's
|
|
321
|
+
nodloop is refused since only the capture of a session records verdicts under it
|
|
322
|
+
Every argument after verdict is keyword only
|
|
323
|
+
So a call of an earlier release that passed the reason third fails instead of naming it the reviewer
|
|
324
|
+
"""
|
|
249
325
|
args: dict[str, Any] = {"trace_id": run, "verdict": verdict}
|
|
250
326
|
for key, value in (("reason", reason), ("reason_code", reason_code), ("reviewer", reviewer)):
|
|
251
327
|
if value:
|
|
@@ -254,23 +330,56 @@ class Client:
|
|
|
254
330
|
args["edited_output"] = edited
|
|
255
331
|
await self.call("feedback", args)
|
|
256
332
|
|
|
257
|
-
async def outcome(
|
|
333
|
+
async def outcome(
|
|
334
|
+
self,
|
|
335
|
+
run: str,
|
|
336
|
+
result: Result,
|
|
337
|
+
confirmed_cause: str = "",
|
|
338
|
+
note: str = "",
|
|
339
|
+
reviewer: str = "",
|
|
340
|
+
*,
|
|
341
|
+
actor: Literal["person", "code", "model", "unknown"] | None = None,
|
|
342
|
+
) -> None:
|
|
258
343
|
"""Records what a real check found for a run: confirmed, refuted or inconclusive
|
|
259
344
|
|
|
260
345
|
A different fact from the verdict on the output
|
|
261
346
|
confirmed_cause only goes with confirmed
|
|
347
|
+
reviewer names who checked and is required over stdio while a server key names itself
|
|
348
|
+
actor names what kind of evaluator checked
|
|
349
|
+
It never proves a person since only the transport does
|
|
262
350
|
"""
|
|
263
351
|
args: dict[str, Any] = {"trace_id": run, "result": result}
|
|
264
|
-
for key, value in (("confirmed_cause", confirmed_cause), ("note", note), ("reviewer", reviewer)):
|
|
352
|
+
for key, value in (("confirmed_cause", confirmed_cause), ("note", note), ("reviewer", reviewer), ("actor", actor)):
|
|
265
353
|
if value:
|
|
266
354
|
args[key] = value
|
|
267
355
|
await self.call("outcome", args)
|
|
268
356
|
|
|
269
357
|
async def report(self, name: ReportName) -> Report:
|
|
270
|
-
"""One report of the records as JSON with the start of the window it read
|
|
358
|
+
"""One report of the records as JSON with the start of the window it read
|
|
359
|
+
|
|
360
|
+
Deprecated: page the raw records with records instead since they carry the published contract
|
|
361
|
+
report is removed in the next release
|
|
362
|
+
"""
|
|
271
363
|
answer = await self.call("report", {"name": name})
|
|
272
364
|
return Report(answer["report"], answer.get("since", ""))
|
|
273
365
|
|
|
366
|
+
async def records(self, kind: RecordKind, since: str = "", limit: int = 0, content: bool = False) -> Page:
|
|
367
|
+
"""One page of the raw records of a kind in its published schema, in the order they were appended after the cursor since
|
|
368
|
+
|
|
369
|
+
Pass next back as since for the following page, so a record appended late with an earlier time still comes once
|
|
370
|
+
limit is 100 by default and at most 1000
|
|
371
|
+
The free text of the kind such as outputs, reasons and lesson content is left out unless content is true
|
|
372
|
+
"""
|
|
373
|
+
args: dict[str, Any] = {"kind": kind}
|
|
374
|
+
if since:
|
|
375
|
+
args["since"] = since
|
|
376
|
+
if limit:
|
|
377
|
+
args["limit"] = limit
|
|
378
|
+
if content:
|
|
379
|
+
args["content"] = True
|
|
380
|
+
answer = await self.call("records", args)
|
|
381
|
+
return Page(answer.get("records") or [], answer.get("next", ""))
|
|
382
|
+
|
|
274
383
|
async def propose(
|
|
275
384
|
self,
|
|
276
385
|
kind: str,
|
|
@@ -300,8 +409,9 @@ class Client:
|
|
|
300
409
|
return Candidate(answer["id"], answer["version"], answer["status"], answer.get("overlaps") or [])
|
|
301
410
|
|
|
302
411
|
async def approve(self, item_id: str, version: int, approver: str, *, labels: dict[str, list[str]] | None = None) -> Approval:
|
|
303
|
-
"""Approves a candidate
|
|
412
|
+
"""Approves a candidate under a key of nodloop-server that a person holds
|
|
304
413
|
|
|
414
|
+
Any other call such as one over stdio records an agent proposal the person is asked about: the status stays and proposal names it
|
|
305
415
|
labels approves it for the runs carrying them instead of the proposed scope and an empty mapping for every run of its producer
|
|
306
416
|
"""
|
|
307
417
|
args: dict[str, Any] = {"id": item_id, "version": version, "approver": approver}
|
|
@@ -314,9 +424,48 @@ class Client:
|
|
|
314
424
|
answer["status"],
|
|
315
425
|
answer["approver"],
|
|
316
426
|
veto=bool(answer.get("veto")),
|
|
317
|
-
compaction_due=bool((answer.get("folder") or {}).get("compaction_due")),
|
|
318
427
|
export_error=answer.get("export_error", ""),
|
|
319
428
|
folder_error=answer.get("folder_error", ""),
|
|
429
|
+
proposal=answer.get("proposal", ""),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
async def intervene(
|
|
433
|
+
self,
|
|
434
|
+
action: InterventionAction,
|
|
435
|
+
target: str = "",
|
|
436
|
+
*,
|
|
437
|
+
choice: str = "",
|
|
438
|
+
labels: dict[str, list[str]] | None = None,
|
|
439
|
+
value: float | None = None,
|
|
440
|
+
session: str = "",
|
|
441
|
+
) -> Intervention:
|
|
442
|
+
"""Submits one intervention of the write side: judge, answer, dismiss, pause, resume, knob, reanchor, digest or reverse
|
|
443
|
+
|
|
444
|
+
target is an item version as id:version, a compaction, the decide of a question, the change kind of a pause,
|
|
445
|
+
the pause a resume ends, a knob such as audit_tier1, the producer of a place or the intervention a reverse takes back
|
|
446
|
+
Under a key of nodloop-server that a person holds it is the person's decision at once
|
|
447
|
+
Any other call such as one over stdio records an agent proposal the person is asked about and changes nothing
|
|
448
|
+
A digest needs no proof and answers since, the cursor after the person's last decision
|
|
449
|
+
"""
|
|
450
|
+
args: dict[str, Any] = {"action": action}
|
|
451
|
+
for key, given in (("target", target), ("choice", choice), ("session", session)):
|
|
452
|
+
if given:
|
|
453
|
+
args[key] = given
|
|
454
|
+
if labels is not None:
|
|
455
|
+
args["labels"] = labels
|
|
456
|
+
if value is not None:
|
|
457
|
+
args["value"] = value
|
|
458
|
+
answer = await self.call("intervene", args)
|
|
459
|
+
return Intervention(
|
|
460
|
+
answer.get("intervention"),
|
|
461
|
+
authority=answer.get("authority", ""),
|
|
462
|
+
proof=answer.get("proof", ""),
|
|
463
|
+
records=answer.get("records") or [],
|
|
464
|
+
answer=answer.get("answer", ""),
|
|
465
|
+
since=answer.get("since", ""),
|
|
466
|
+
question=answer.get("question", ""),
|
|
467
|
+
declined=bool(answer.get("declined")),
|
|
468
|
+
export_error=answer.get("export_error", ""),
|
|
320
469
|
)
|
|
321
470
|
|
|
322
471
|
async def check_call(self, tool: str, arguments: dict[str, Any], producer: str = "") -> Decision:
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import time
|
|
10
11
|
from collections.abc import Callable
|
|
11
12
|
from typing import Any
|
|
12
13
|
|
|
@@ -43,12 +44,15 @@ class NodloopMiddleware(AgentMiddleware):
|
|
|
43
44
|
self._knowledge: Knowledge | None = None
|
|
44
45
|
# The run the final answer was recorded as so a verdict can cite it
|
|
45
46
|
self.run: str = ""
|
|
47
|
+
# When the first model call of the run came so the run records how long it took
|
|
48
|
+
self._started = time.monotonic()
|
|
46
49
|
|
|
47
50
|
def _labels(self, state: Any) -> Labels:
|
|
48
51
|
return self.labels(state) if callable(self.labels) else self.labels
|
|
49
52
|
|
|
50
53
|
async def awrap_model_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
|
|
51
54
|
if self._knowledge is None:
|
|
55
|
+
self._started = time.monotonic()
|
|
52
56
|
self._knowledge = await self.client.knowledge(self.producer, self._labels(request.state), self.holdout_key)
|
|
53
57
|
self.applied, self.withheld = self._knowledge.items, self._knowledge.withheld
|
|
54
58
|
text = self._knowledge.text
|
|
@@ -66,6 +70,7 @@ class NodloopMiddleware(AgentMiddleware):
|
|
|
66
70
|
applied=self.applied,
|
|
67
71
|
withheld=self.withheld,
|
|
68
72
|
holdout_key=self.holdout_key,
|
|
73
|
+
duration_ms=int((time.monotonic() - self._started) * 1000),
|
|
69
74
|
)
|
|
70
75
|
self._knowledge = None
|
|
71
76
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import time
|
|
11
12
|
from collections.abc import Callable
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
@@ -34,11 +35,14 @@ class NodloopHooks(RunHooks):
|
|
|
34
35
|
self.withheld: list[Ref] = []
|
|
35
36
|
# The run the final output was recorded as so a verdict can cite it
|
|
36
37
|
self.run: str = ""
|
|
38
|
+
# When the instructions were built so the run records how long it took
|
|
39
|
+
self._started = time.monotonic()
|
|
37
40
|
|
|
38
41
|
def instructions(self, base: str) -> Callable[[Any, Any], Any]:
|
|
39
42
|
"""Dynamic instructions: the base and the approved items under it"""
|
|
40
43
|
|
|
41
44
|
async def build(context: Any, agent: Any) -> str:
|
|
45
|
+
self._started = time.monotonic()
|
|
42
46
|
knowledge = await self.client.knowledge(self.producer, self.labels, self.holdout_key)
|
|
43
47
|
self.applied, self.withheld = knowledge.items, knowledge.withheld
|
|
44
48
|
return f"{base}\n\n{knowledge.text}" if knowledge.text else base
|
|
@@ -48,7 +52,13 @@ class NodloopHooks(RunHooks):
|
|
|
48
52
|
async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
|
|
49
53
|
text = output if isinstance(output, str) else json.dumps(output, default=str)
|
|
50
54
|
self.run = await self.client.record(
|
|
51
|
-
self.producer,
|
|
55
|
+
self.producer,
|
|
56
|
+
text,
|
|
57
|
+
labels=self.labels,
|
|
58
|
+
applied=self.applied,
|
|
59
|
+
withheld=self.withheld,
|
|
60
|
+
holdout_key=self.holdout_key,
|
|
61
|
+
duration_ms=int((time.monotonic() - self._started) * 1000),
|
|
52
62
|
)
|
|
53
63
|
|
|
54
64
|
|