nodloop 0.9.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nodloop
3
- Version: 0.9.0
3
+ Version: 0.10.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
@@ -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, reviewer)` | nothing |
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=)` | 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 |
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, reviewer)` | nothing |
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=)` | 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 |
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:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nodloop"
3
- version = "0.9.0"
3
+ version = "0.10.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"
@@ -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, answer, labels=self.labels, applied=self.applied, withheld=self.withheld, holdout_key=self.holdout_key
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
- __all__ = ["Approval", "Candidate", "Client", "Decision", "Item", "Knowledge", "Ref", "Report", "ReportName", "Result", "ToolError"]
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.9.0"
36
+ VERSION = "0.10.0"
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 approved version carries a veto the guard now applies
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:
@@ -259,6 +335,7 @@ class Client:
259
335
 
260
336
  A different fact from the verdict on the output
261
337
  confirmed_cause only goes with confirmed
338
+ reviewer names who checked and is required over stdio while a server key names itself
262
339
  """
263
340
  args: dict[str, Any] = {"trace_id": run, "result": result}
264
341
  for key, value in (("confirmed_cause", confirmed_cause), ("note", note), ("reviewer", reviewer)):
@@ -267,10 +344,31 @@ class Client:
267
344
  await self.call("outcome", args)
268
345
 
269
346
  async def report(self, name: ReportName) -> Report:
270
- """One report of the records as JSON with the start of the window it read"""
347
+ """One report of the records as JSON with the start of the window it read
348
+
349
+ Deprecated: page the raw records with records instead since they carry the published contract
350
+ report is removed in the next release
351
+ """
271
352
  answer = await self.call("report", {"name": name})
272
353
  return Report(answer["report"], answer.get("since", ""))
273
354
 
355
+ async def records(self, kind: RecordKind, since: str = "", limit: int = 0, content: bool = False) -> Page:
356
+ """One page of the raw records of a kind in its published schema, in the order they were appended after the cursor since
357
+
358
+ Pass next back as since for the following page, so a record appended late with an earlier time still comes once
359
+ limit is 100 by default and at most 1000
360
+ The free text of the kind such as outputs, reasons and lesson content is left out unless content is true
361
+ """
362
+ args: dict[str, Any] = {"kind": kind}
363
+ if since:
364
+ args["since"] = since
365
+ if limit:
366
+ args["limit"] = limit
367
+ if content:
368
+ args["content"] = True
369
+ answer = await self.call("records", args)
370
+ return Page(answer.get("records") or [], answer.get("next", ""))
371
+
274
372
  async def propose(
275
373
  self,
276
374
  kind: str,
@@ -300,8 +398,9 @@ class Client:
300
398
  return Candidate(answer["id"], answer["version"], answer["status"], answer.get("overlaps") or [])
301
399
 
302
400
  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
401
+ """Approves a candidate under a key of nodloop-server that a person holds
304
402
 
403
+ 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
404
  labels approves it for the runs carrying them instead of the proposed scope and an empty mapping for every run of its producer
306
405
  """
307
406
  args: dict[str, Any] = {"id": item_id, "version": version, "approver": approver}
@@ -314,9 +413,48 @@ class Client:
314
413
  answer["status"],
315
414
  answer["approver"],
316
415
  veto=bool(answer.get("veto")),
317
- compaction_due=bool((answer.get("folder") or {}).get("compaction_due")),
318
416
  export_error=answer.get("export_error", ""),
319
417
  folder_error=answer.get("folder_error", ""),
418
+ proposal=answer.get("proposal", ""),
419
+ )
420
+
421
+ async def intervene(
422
+ self,
423
+ action: InterventionAction,
424
+ target: str = "",
425
+ *,
426
+ choice: str = "",
427
+ labels: dict[str, list[str]] | None = None,
428
+ value: float | None = None,
429
+ session: str = "",
430
+ ) -> Intervention:
431
+ """Submits one intervention of the write side: judge, answer, dismiss, pause, resume, knob, reanchor, digest or reverse
432
+
433
+ target is an item version as id:version, a compaction, the decide of a question, the change kind of a pause,
434
+ the pause a resume ends, a knob such as audit_tier1, the producer of a place or the intervention a reverse takes back
435
+ Under a key of nodloop-server that a person holds it is the person's decision at once
436
+ Any other call such as one over stdio records an agent proposal the person is asked about and changes nothing
437
+ A digest needs no proof and answers since, the cursor after the person's last decision
438
+ """
439
+ args: dict[str, Any] = {"action": action}
440
+ for key, given in (("target", target), ("choice", choice), ("session", session)):
441
+ if given:
442
+ args[key] = given
443
+ if labels is not None:
444
+ args["labels"] = labels
445
+ if value is not None:
446
+ args["value"] = value
447
+ answer = await self.call("intervene", args)
448
+ return Intervention(
449
+ answer.get("intervention"),
450
+ authority=answer.get("authority", ""),
451
+ proof=answer.get("proof", ""),
452
+ records=answer.get("records") or [],
453
+ answer=answer.get("answer", ""),
454
+ since=answer.get("since", ""),
455
+ question=answer.get("question", ""),
456
+ declined=bool(answer.get("declined")),
457
+ export_error=answer.get("export_error", ""),
320
458
  )
321
459
 
322
460
  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, text, labels=self.labels, applied=self.applied, withheld=self.withheld, holdout_key=self.holdout_key
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