nookku 0.4.0__py3-none-any.whl
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.
- nookku/__init__.py +3 -0
- nookku/__main__.py +21 -0
- nookku/adapters.py +214 -0
- nookku/agent.py +51 -0
- nookku/audit.py +279 -0
- nookku/backend.py +394 -0
- nookku/bridge.py +802 -0
- nookku/claude_gate.py +115 -0
- nookku/cli.py +635 -0
- nookku/codex_gate.py +267 -0
- nookku/commands.py +673 -0
- nookku/config.py +185 -0
- nookku/contract.py +79 -0
- nookku/evaluate.md +50 -0
- nookku/evaluation.py +349 -0
- nookku/hook.py +36 -0
- nookku/kit.py +1135 -0
- nookku/mcp.py +301 -0
- nookku/model_api.py +422 -0
- nookku/otlp.py +489 -0
- nookku/policy.py +243 -0
- nookku/py.typed +0 -0
- nookku/record.py +303 -0
- nookku/script.py +90 -0
- nookku/seal.py +164 -0
- nookku/setup.md +75 -0
- nookku/state.py +182 -0
- nookku/stdio.py +335 -0
- nookku/tap.py +367 -0
- nookku/trace.py +921 -0
- nookku-0.4.0.dist-info/METADATA +152 -0
- nookku-0.4.0.dist-info/RECORD +35 -0
- nookku-0.4.0.dist-info/WHEEL +4 -0
- nookku-0.4.0.dist-info/entry_points.txt +2 -0
- nookku-0.4.0.dist-info/licenses/LICENSE +202 -0
nookku/__init__.py
ADDED
nookku/__main__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The `nookku` command. A hook event takes a short path that does not import the CLI (#214)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
10
|
+
args = sys.argv[1:] if argv is None else list(argv)
|
|
11
|
+
if args[:1] == ["hook"]:
|
|
12
|
+
from nookku.hook import main as hook
|
|
13
|
+
|
|
14
|
+
return hook(args[1:])
|
|
15
|
+
from nookku.cli import main as cli
|
|
16
|
+
|
|
17
|
+
return cli(args)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if __name__ == "__main__":
|
|
21
|
+
sys.exit(main())
|
nookku/adapters.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Adapters of SPEC.md section 4.1: find the message in a request and the reply in a response."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AdapterError(ValueError):
|
|
11
|
+
"""The adapter cannot find the message or the reply."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StreamError(ValueError):
|
|
15
|
+
"""The agent's stream failed: it ended early, sent an error, or sent a malformed chunk."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# One earlier turn of the session: (tester message, agent reply).
|
|
19
|
+
History = list[tuple[str, str]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Adapter(Protocol):
|
|
23
|
+
def request(self, message: str, history: History) -> bytes: ...
|
|
24
|
+
def accepts(self, method: str, path: str) -> bool: ...
|
|
25
|
+
def message(self, body: bytes) -> str: ...
|
|
26
|
+
def reply(self, body: bytes) -> str: ...
|
|
27
|
+
def stream_reply(self, body: bytes) -> str: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_stream(content_type: str | None) -> bool:
|
|
31
|
+
"""True if a Content-Type is text/event-stream (SPEC.md section 4.1)."""
|
|
32
|
+
return (content_type or "").split(";")[0].strip().lower() == "text/event-stream"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def sse_events(body: bytes) -> list[tuple[str, str]]:
|
|
36
|
+
"""The (name, data) of each event of an SSE body, in the order of the body.
|
|
37
|
+
|
|
38
|
+
The rules are those of the WHATWG HTML standard: a blank line ends an event, a line that starts
|
|
39
|
+
with a colon is a comment, and an event without a data line is not an event. The last event
|
|
40
|
+
does not need a blank line after it. The `openai` adapter and the model API proxies
|
|
41
|
+
(`model_api.events`) use this one parser.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
text = body.decode("utf-8").removeprefix("")
|
|
45
|
+
except UnicodeDecodeError:
|
|
46
|
+
raise StreamError("the stream is not UTF-8") from None
|
|
47
|
+
events: list[tuple[str, str]] = []
|
|
48
|
+
name, data = "", list[str]()
|
|
49
|
+
for line in [*re.split(r"\r\n|\r|\n", text), ""]:
|
|
50
|
+
if line == "":
|
|
51
|
+
if data:
|
|
52
|
+
events.append((name or "message", "\n".join(data)))
|
|
53
|
+
name, data = "", []
|
|
54
|
+
elif not line.startswith(":"):
|
|
55
|
+
key, _, value = line.partition(":")
|
|
56
|
+
value = value.removeprefix(" ")
|
|
57
|
+
if key == "data":
|
|
58
|
+
data.append(value)
|
|
59
|
+
elif key == "event":
|
|
60
|
+
name = value
|
|
61
|
+
return events
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _json(body: bytes, what: str) -> Any:
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(body.decode("utf-8"))
|
|
67
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
68
|
+
raise AdapterError(f"the {what} body is not UTF-8 JSON") from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pick(data: Any, path: str) -> Any:
|
|
72
|
+
"""Follow a dot path such as `choices.0.message.content`."""
|
|
73
|
+
for key in path.split("."):
|
|
74
|
+
if isinstance(data, list) and key.isdigit() and int(key) < len(data):
|
|
75
|
+
data = data[int(key)]
|
|
76
|
+
elif isinstance(data, dict) and key in data:
|
|
77
|
+
data = data[key]
|
|
78
|
+
else:
|
|
79
|
+
raise AdapterError(f"no value at {path!r}")
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _text(value: Any, path: str) -> str:
|
|
84
|
+
if not isinstance(value, str):
|
|
85
|
+
raise AdapterError(f"the value at {path!r} is not a string")
|
|
86
|
+
return value
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class JsonAdapter:
|
|
90
|
+
"""Any POST. The message and the reply are at field paths of JSON bodies."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, message_field: str = "text", reply_field: str = "reply") -> None:
|
|
93
|
+
self.message_field, self.reply_field = message_field, reply_field
|
|
94
|
+
|
|
95
|
+
def request(self, message: str, history: History) -> bytes:
|
|
96
|
+
body: Any = message
|
|
97
|
+
for key in reversed(self.message_field.split(".")):
|
|
98
|
+
body = {key: body}
|
|
99
|
+
return json.dumps(body, ensure_ascii=False).encode()
|
|
100
|
+
|
|
101
|
+
def accepts(self, method: str, path: str) -> bool:
|
|
102
|
+
return method == "POST"
|
|
103
|
+
|
|
104
|
+
def message(self, body: bytes) -> str:
|
|
105
|
+
return _text(pick(_json(body, "request"), self.message_field), self.message_field)
|
|
106
|
+
|
|
107
|
+
def reply(self, body: bytes) -> str:
|
|
108
|
+
return _text(pick(_json(body, "response"), self.reply_field), self.reply_field)
|
|
109
|
+
|
|
110
|
+
def stream_reply(self, body: bytes) -> str:
|
|
111
|
+
raise AdapterError("the json adapter does not read a streamed response")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class OpenAIAdapter:
|
|
115
|
+
"""A POST to an OpenAI-compatible `/chat/completions` endpoint. The reply can be a stream."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, model: str = "", stream: bool = False) -> None:
|
|
118
|
+
self.model, self.stream = model, stream
|
|
119
|
+
|
|
120
|
+
def request(self, message: str, history: History) -> bytes:
|
|
121
|
+
messages = []
|
|
122
|
+
for said, reply in history:
|
|
123
|
+
messages += [{"role": "user", "content": said}, {"role": "assistant", "content": reply}]
|
|
124
|
+
messages.append({"role": "user", "content": message})
|
|
125
|
+
# With the option openai_stream, the request asks for a stream (SPEC.md section 5).
|
|
126
|
+
body: dict[str, Any] = {"messages": messages, "stream": self.stream}
|
|
127
|
+
if self.model:
|
|
128
|
+
body["model"] = self.model
|
|
129
|
+
return json.dumps(body, ensure_ascii=False).encode()
|
|
130
|
+
|
|
131
|
+
def accepts(self, method: str, path: str) -> bool:
|
|
132
|
+
return method == "POST" and path.split("?")[0].rstrip("/").endswith("/chat/completions")
|
|
133
|
+
|
|
134
|
+
def message(self, body: bytes) -> str:
|
|
135
|
+
messages = pick(_json(body, "request"), "messages")
|
|
136
|
+
if not isinstance(messages, list):
|
|
137
|
+
raise AdapterError("'messages' is not a list")
|
|
138
|
+
users = [m for m in messages if isinstance(m, dict) and m.get("role") == "user"]
|
|
139
|
+
if not users:
|
|
140
|
+
raise AdapterError("the request has no message with role 'user'")
|
|
141
|
+
content = users[-1].get("content")
|
|
142
|
+
if isinstance(content, list):
|
|
143
|
+
texts = [p for p in content if isinstance(p, dict) and p.get("type") == "text"]
|
|
144
|
+
if len(texts) != 1 or len(content) != 1:
|
|
145
|
+
raise AdapterError("the user content must be a string or exactly one text part")
|
|
146
|
+
content = texts[0].get("text")
|
|
147
|
+
return _text(content, "messages[-1].content")
|
|
148
|
+
|
|
149
|
+
def reply(self, body: bytes) -> str:
|
|
150
|
+
path = "choices.0.message.content"
|
|
151
|
+
return _text(pick(_json(body, "response"), path), path)
|
|
152
|
+
|
|
153
|
+
def stream_reply(self, body: bytes) -> str:
|
|
154
|
+
"""Join the `choices[0].delta.content` texts of a stream (SPEC.md section 4.1).
|
|
155
|
+
|
|
156
|
+
Raise StreamError if the stream has no `data: [DONE]` event, has an event after it, has an
|
|
157
|
+
error, or has a malformed chunk. Raise AdapterError if no chunk has a content text.
|
|
158
|
+
"""
|
|
159
|
+
parts: list[str] = []
|
|
160
|
+
done = False
|
|
161
|
+
for name, data in sse_events(body):
|
|
162
|
+
if done:
|
|
163
|
+
raise StreamError("the stream has an event after data: [DONE]")
|
|
164
|
+
if data == "[DONE]":
|
|
165
|
+
done = True
|
|
166
|
+
continue
|
|
167
|
+
if name == "error":
|
|
168
|
+
raise StreamError(f"the agent sent an error event: {data}")
|
|
169
|
+
try:
|
|
170
|
+
chunk = json.loads(data)
|
|
171
|
+
except json.JSONDecodeError:
|
|
172
|
+
raise StreamError(f"a chunk is not JSON: {data[:80]!r}") from None
|
|
173
|
+
if not isinstance(chunk, dict):
|
|
174
|
+
raise StreamError(f"a chunk is not a JSON object: {data[:80]!r}")
|
|
175
|
+
if chunk.get("error") is not None:
|
|
176
|
+
raise StreamError(f"the agent sent an error: {json.dumps(chunk['error'])}")
|
|
177
|
+
choices = chunk.get("choices", [])
|
|
178
|
+
if not isinstance(choices, list):
|
|
179
|
+
raise StreamError("the 'choices' of a chunk is not a list")
|
|
180
|
+
for choice in choices:
|
|
181
|
+
delta = choice.get("delta", {}) if isinstance(choice, dict) else None
|
|
182
|
+
if not isinstance(delta, dict):
|
|
183
|
+
raise StreamError("a choice of a chunk has no 'delta' object")
|
|
184
|
+
if choice.get("index", 0) != 0:
|
|
185
|
+
continue
|
|
186
|
+
content = delta.get("content")
|
|
187
|
+
if content is not None and not isinstance(content, str):
|
|
188
|
+
raise StreamError("the 'delta.content' of a chunk is not a string")
|
|
189
|
+
if content is not None:
|
|
190
|
+
parts.append(content)
|
|
191
|
+
if not done:
|
|
192
|
+
raise StreamError("the stream ended before data: [DONE]")
|
|
193
|
+
if not parts:
|
|
194
|
+
raise AdapterError("no chunk of the stream has a 'delta.content' text")
|
|
195
|
+
# A chunk can end in the middle of a surrogate pair. Join each pair into one character, as
|
|
196
|
+
# a JavaScript string does. A lone surrogate is not a Unicode scalar value (SPEC.md 2).
|
|
197
|
+
try:
|
|
198
|
+
return "".join(parts).encode("utf-16-le", "surrogatepass").decode("utf-16-le")
|
|
199
|
+
except UnicodeDecodeError:
|
|
200
|
+
raise StreamError("the stream reply has a lone surrogate") from None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def make(
|
|
204
|
+
name: str,
|
|
205
|
+
message_field: str = "text",
|
|
206
|
+
reply_field: str = "reply",
|
|
207
|
+
model: str = "",
|
|
208
|
+
stream: bool = False,
|
|
209
|
+
) -> Adapter:
|
|
210
|
+
if name == "json":
|
|
211
|
+
return JsonAdapter(message_field, reply_field)
|
|
212
|
+
if name == "openai":
|
|
213
|
+
return OpenAIAdapter(model, stream)
|
|
214
|
+
raise ValueError(f"unknown adapter {name!r}")
|
nookku/agent.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""A helper for an entry in Python: speak the agent contract (SPEC.md section 6) for a function.
|
|
2
|
+
|
|
3
|
+
from nookku.agent import serve
|
|
4
|
+
|
|
5
|
+
def reply(message: str, history: list[tuple[str, str]]) -> str:
|
|
6
|
+
return shop.answer(message)
|
|
7
|
+
|
|
8
|
+
serve(reply)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
from nookku import contract
|
|
19
|
+
from nookku.adapters import History
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _line(rid: str, **fields: str) -> bytes:
|
|
23
|
+
return json.dumps({"v": contract.VERSION, "id": rid, **fields}, ensure_ascii=False).encode()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def serve(reply: Callable[[str, History], str]) -> None:
|
|
27
|
+
"""Answer each input line on stdin with one output line, until stdin closes.
|
|
28
|
+
|
|
29
|
+
Stdout carries only contract lines. All other output of the process, from print() or from a
|
|
30
|
+
child process, goes to stderr. This includes text that the process printed before serve() and
|
|
31
|
+
that is still in the stdout buffer.
|
|
32
|
+
"""
|
|
33
|
+
out = os.fdopen(os.dup(1), "wb", buffering=0)
|
|
34
|
+
os.dup2(2, 1)
|
|
35
|
+
# Flush after dup2, so that the buffered text goes to stderr and not to the contract channel.
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
for raw in sys.stdin.buffer:
|
|
38
|
+
line = raw.removesuffix(b"\n").removesuffix(b"\r")
|
|
39
|
+
try:
|
|
40
|
+
rid, message, history = contract.parse_request(line)
|
|
41
|
+
except contract.ContractError as e:
|
|
42
|
+
out.write(_line("", error=f"nookku agent: {e}") + b"\n")
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
text = reply(message, history)
|
|
46
|
+
if not isinstance(text, str):
|
|
47
|
+
raise TypeError(f"reply() returned {type(text).__name__}, not str")
|
|
48
|
+
result = _line(rid, reply=text)
|
|
49
|
+
except Exception as e: # the error goes back to the tester as an error turn
|
|
50
|
+
result = _line(rid, error=f"{type(e).__name__}: {e}")
|
|
51
|
+
out.write(result + b"\n")
|
nookku/audit.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""The audit of SPEC.md section 3: compare the relay record with the tap record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from itertools import pairwise
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from nookku.record import (
|
|
11
|
+
BlockedCall,
|
|
12
|
+
Exchange,
|
|
13
|
+
ModelSession,
|
|
14
|
+
RecordError,
|
|
15
|
+
Turn,
|
|
16
|
+
Unparsed,
|
|
17
|
+
read_relay,
|
|
18
|
+
read_tap,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
CHECKS = [
|
|
22
|
+
"altered_input",
|
|
23
|
+
"injected_input",
|
|
24
|
+
"duplicate_send",
|
|
25
|
+
"out_of_order",
|
|
26
|
+
"not_delivered",
|
|
27
|
+
"altered_reply",
|
|
28
|
+
"unshown_reply",
|
|
29
|
+
"script_mismatch",
|
|
30
|
+
]
|
|
31
|
+
EXCERPT = 24
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Finding:
|
|
36
|
+
kind: str
|
|
37
|
+
relay_line: int | None
|
|
38
|
+
tap_line: int | None
|
|
39
|
+
evidence: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
def as_dict(self) -> dict[str, Any]:
|
|
42
|
+
return {
|
|
43
|
+
"class": self.kind,
|
|
44
|
+
"relay_line": self.relay_line,
|
|
45
|
+
"tap_line": self.tap_line,
|
|
46
|
+
"evidence": self.evidence,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Report:
|
|
52
|
+
exit: int
|
|
53
|
+
turns: int = 0
|
|
54
|
+
exchanges: int = 0
|
|
55
|
+
blocked_calls: int = 0
|
|
56
|
+
model_sessions: int = 0
|
|
57
|
+
breaks: list[Finding] = field(default_factory=list)
|
|
58
|
+
notes: list[Finding] = field(default_factory=list)
|
|
59
|
+
errors: list[dict[str, str]] = field(default_factory=list)
|
|
60
|
+
skipped: list[str] = field(default_factory=list)
|
|
61
|
+
|
|
62
|
+
def as_dict(self) -> dict[str, Any]:
|
|
63
|
+
return {
|
|
64
|
+
"v": "0.2",
|
|
65
|
+
"exit": self.exit,
|
|
66
|
+
"turns": self.turns,
|
|
67
|
+
"exchanges": self.exchanges,
|
|
68
|
+
"blocked_calls": self.blocked_calls,
|
|
69
|
+
"model_sessions": self.model_sessions,
|
|
70
|
+
"breaks": [b.as_dict() for b in self.breaks],
|
|
71
|
+
"notes": [n.as_dict() for n in self.notes],
|
|
72
|
+
"errors": self.errors,
|
|
73
|
+
"skipped": self.skipped,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def difference(expected: str, actual: str) -> dict[str, Any]:
|
|
78
|
+
"""Locate the first character where two texts differ, with an excerpt of each side."""
|
|
79
|
+
i = next(
|
|
80
|
+
(k for k, (a, b) in enumerate(zip(expected, actual, strict=False)) if a != b),
|
|
81
|
+
min(len(expected), len(actual)),
|
|
82
|
+
)
|
|
83
|
+
start = max(0, i - EXCERPT // 2)
|
|
84
|
+
return {
|
|
85
|
+
"first_difference": i,
|
|
86
|
+
"expected": expected[start : start + EXCERPT],
|
|
87
|
+
"actual": actual[start : start + EXCERPT],
|
|
88
|
+
"expected_length": len(expected),
|
|
89
|
+
"actual_length": len(actual),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _anchors(turns: list[Turn], exchanges: list[Exchange]) -> list[tuple[int, int]]:
|
|
94
|
+
"""Return index pairs of a longest common subsequence, built in file order (SPEC 3.2 step 1)."""
|
|
95
|
+
n, m = len(turns), len(exchanges)
|
|
96
|
+
longest = [[0] * (m + 1) for _ in range(n + 1)]
|
|
97
|
+
for i in range(n - 1, -1, -1):
|
|
98
|
+
for j in range(m - 1, -1, -1):
|
|
99
|
+
if turns[i].said == exchanges[j].input:
|
|
100
|
+
longest[i][j] = 1 + longest[i + 1][j + 1]
|
|
101
|
+
else:
|
|
102
|
+
longest[i][j] = max(longest[i + 1][j], longest[i][j + 1])
|
|
103
|
+
pairs, i, j = [], 0, 0
|
|
104
|
+
while i < n and j < m:
|
|
105
|
+
if turns[i].said == exchanges[j].input and longest[i][j] == 1 + longest[i + 1][j + 1]:
|
|
106
|
+
pairs.append((i, j))
|
|
107
|
+
i, j = i + 1, j + 1
|
|
108
|
+
elif longest[i + 1][j] == longest[i][j]:
|
|
109
|
+
i += 1
|
|
110
|
+
else:
|
|
111
|
+
j += 1
|
|
112
|
+
return pairs
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compare(turns: list[Turn], exchanges: list[Exchange]) -> tuple[list[Finding], list[Finding]]:
|
|
116
|
+
"""Return (breaks, notes) for two valid records."""
|
|
117
|
+
breaks: list[Finding] = []
|
|
118
|
+
anchors = _anchors(turns, exchanges)
|
|
119
|
+
pairs = list(anchors)
|
|
120
|
+
free_t = [i for i in range(len(turns)) if i not in {a for a, _ in anchors}]
|
|
121
|
+
free_e = [j for j in range(len(exchanges)) if j not in {b for _, b in anchors}]
|
|
122
|
+
|
|
123
|
+
# Step 2: a message that arrived at another position.
|
|
124
|
+
for i in list(free_t):
|
|
125
|
+
j = next((j for j in free_e if exchanges[j].input == turns[i].said), None)
|
|
126
|
+
if j is not None:
|
|
127
|
+
free_t.remove(i)
|
|
128
|
+
free_e.remove(j)
|
|
129
|
+
pairs.append((i, j))
|
|
130
|
+
breaks.append(Finding("out_of_order", turns[i].line, exchanges[j].line))
|
|
131
|
+
|
|
132
|
+
# Step 3: an extra copy of a message that the tester typed.
|
|
133
|
+
said = {t.said for t in turns}
|
|
134
|
+
for j in [j for j in free_e if exchanges[j].input in said]:
|
|
135
|
+
free_e.remove(j)
|
|
136
|
+
breaks.append(Finding("duplicate_send", None, exchanges[j].line))
|
|
137
|
+
|
|
138
|
+
# Step 4: pair what is left in each gap between adjacent anchors as changed input.
|
|
139
|
+
bounds = [(-1, -1), *anchors, (len(turns), len(exchanges))]
|
|
140
|
+
for (ti, ej), (tk, ek) in pairwise(bounds):
|
|
141
|
+
gap_t = [i for i in free_t if ti < i < tk]
|
|
142
|
+
gap_e = [j for j in free_e if ej < j < ek]
|
|
143
|
+
for i, j in zip(gap_t, gap_e, strict=False):
|
|
144
|
+
free_t.remove(i)
|
|
145
|
+
free_e.remove(j)
|
|
146
|
+
pairs.append((i, j))
|
|
147
|
+
breaks.append(
|
|
148
|
+
Finding(
|
|
149
|
+
"altered_input",
|
|
150
|
+
turns[i].line,
|
|
151
|
+
exchanges[j].line,
|
|
152
|
+
difference(turns[i].said, exchanges[j].input),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Step 5: what is still left.
|
|
157
|
+
breaks += [Finding("injected_input", None, exchanges[j].line) for j in free_e]
|
|
158
|
+
breaks += [Finding("not_delivered", turns[i].line, None) for i in free_t]
|
|
159
|
+
|
|
160
|
+
# Step 6: the reply of each pair.
|
|
161
|
+
notes: list[Finding] = []
|
|
162
|
+
for i, j in pairs:
|
|
163
|
+
t, e = turns[i], exchanges[j]
|
|
164
|
+
# A null reply: a status that is not 2xx, no response, or a failed stream.
|
|
165
|
+
if e.reply is None:
|
|
166
|
+
notes.append(Finding("agent_error", t.line, e.line, {"status": e.status}))
|
|
167
|
+
elif t.shown is None:
|
|
168
|
+
breaks.append(Finding("unshown_reply", t.line, e.line))
|
|
169
|
+
elif t.shown != e.reply:
|
|
170
|
+
breaks.append(Finding("altered_reply", t.line, e.line, difference(e.reply, t.shown)))
|
|
171
|
+
|
|
172
|
+
def order(f: Finding) -> tuple[int, int, int]:
|
|
173
|
+
big = 1 << 30
|
|
174
|
+
return (
|
|
175
|
+
f.relay_line or big,
|
|
176
|
+
f.tap_line or big,
|
|
177
|
+
CHECKS.index(f.kind) if f.kind in CHECKS else 0,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return sorted(breaks, key=order), sorted(notes, key=order)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def compare_script(turns: list[Turn], messages: list[str]) -> list[Finding]:
|
|
184
|
+
"""The script_mismatch breaks of a scripted test (SPEC.md section 3.2, step 7). Turn n must
|
|
185
|
+
be a script turn whose said is script message n, and the counts must be equal."""
|
|
186
|
+
found = []
|
|
187
|
+
for n in range(max(len(turns), len(messages))):
|
|
188
|
+
turn = turns[n] if n < len(turns) else None
|
|
189
|
+
message = messages[n] if n < len(messages) else None
|
|
190
|
+
evidence: dict[str, Any] = {"script_message": n + 1 if message is not None else None}
|
|
191
|
+
if turn is None:
|
|
192
|
+
found.append(Finding("script_mismatch", None, None, evidence))
|
|
193
|
+
elif message is None or turn.harness != "script":
|
|
194
|
+
evidence["harness"] = turn.harness
|
|
195
|
+
found.append(Finding("script_mismatch", turn.line, None, evidence))
|
|
196
|
+
elif turn.said != message:
|
|
197
|
+
evidence.update(difference(message, turn.said))
|
|
198
|
+
found.append(Finding("script_mismatch", turn.line, None, evidence))
|
|
199
|
+
return found
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def audit(tap_path: Path, relay_path: Path, script_path: Path | None = None) -> Report:
|
|
203
|
+
"""Compare the records. With a script, also compare the relay record with the script."""
|
|
204
|
+
from nookku import script
|
|
205
|
+
|
|
206
|
+
errors, tap_rows, relay_rows = [], None, None
|
|
207
|
+
for name, path, reader in (("tap", tap_path, read_tap), ("relay", relay_path, read_relay)):
|
|
208
|
+
try:
|
|
209
|
+
rows = reader(path)
|
|
210
|
+
except RecordError as e:
|
|
211
|
+
errors.append({"class": e.kind, "record": name, "detail": str(e)})
|
|
212
|
+
continue
|
|
213
|
+
if name == "tap":
|
|
214
|
+
tap_rows = rows
|
|
215
|
+
else:
|
|
216
|
+
relay_rows = rows
|
|
217
|
+
for row in tap_rows or []:
|
|
218
|
+
if isinstance(row, Unparsed):
|
|
219
|
+
errors.append(
|
|
220
|
+
{
|
|
221
|
+
"class": "tap_unparsed",
|
|
222
|
+
"record": "tap",
|
|
223
|
+
"detail": f"line {row.line}: {row.method} {row.path}: {row.error}",
|
|
224
|
+
}
|
|
225
|
+
)
|
|
226
|
+
messages: list[str] = []
|
|
227
|
+
if script_path is not None:
|
|
228
|
+
try:
|
|
229
|
+
messages = script.read(script_path)[0]
|
|
230
|
+
except script.ScriptError as e:
|
|
231
|
+
kind = "record_missing" if e.unreadable else "record_invalid"
|
|
232
|
+
errors.append({"class": kind, "record": "script", "detail": str(e)})
|
|
233
|
+
if errors:
|
|
234
|
+
skipped = CHECKS if script_path is not None else CHECKS[:-1]
|
|
235
|
+
return Report(exit=2, errors=errors, skipped=list(skipped))
|
|
236
|
+
assert tap_rows is not None and relay_rows is not None
|
|
237
|
+
turns = [r for r in relay_rows if isinstance(r, Turn)]
|
|
238
|
+
exchanges = [r for r in tap_rows if isinstance(r, Exchange)]
|
|
239
|
+
breaks, notes = compare(turns, exchanges)
|
|
240
|
+
if script_path is not None:
|
|
241
|
+
breaks += compare_script(turns, messages)
|
|
242
|
+
return Report(
|
|
243
|
+
exit=1 if breaks else 0,
|
|
244
|
+
turns=len(turns),
|
|
245
|
+
exchanges=len(exchanges),
|
|
246
|
+
blocked_calls=sum(isinstance(r, BlockedCall) for r in relay_rows),
|
|
247
|
+
model_sessions=sum(isinstance(r, ModelSession) for r in tap_rows),
|
|
248
|
+
breaks=breaks,
|
|
249
|
+
notes=notes,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def render(report: Report) -> str:
|
|
254
|
+
"""A short text report for a terminal."""
|
|
255
|
+
out = []
|
|
256
|
+
if report.errors:
|
|
257
|
+
out += [f"ERROR {e['class']}: {e['detail']}" for e in report.errors]
|
|
258
|
+
out.append("Skipped checks: " + ", ".join(report.skipped))
|
|
259
|
+
out.append("Result: the audit cannot run (exit 2)")
|
|
260
|
+
return "\n".join(out)
|
|
261
|
+
for b in report.breaks:
|
|
262
|
+
where = f"relay line {b.relay_line or '-'}, tap line {b.tap_line or '-'}"
|
|
263
|
+
line = f"BREAK {b.kind:<15} {where}"
|
|
264
|
+
if "first_difference" in b.evidence:
|
|
265
|
+
ev = b.evidence
|
|
266
|
+
line += (
|
|
267
|
+
f": first difference at character {ev['first_difference']}:"
|
|
268
|
+
f" expected {ev['expected']!r}, got {ev['actual']!r}"
|
|
269
|
+
)
|
|
270
|
+
out.append(line)
|
|
271
|
+
for n in report.notes:
|
|
272
|
+
out.append(f"NOTE {n.kind:<15} relay line {n.relay_line}, tap line {n.tap_line}")
|
|
273
|
+
out.append(
|
|
274
|
+
f"{report.turns} turns, {report.exchanges} exchanges, "
|
|
275
|
+
f"{report.blocked_calls} blocked model calls, {report.model_sessions} model sessions, "
|
|
276
|
+
f"{len(report.breaks)} breaks"
|
|
277
|
+
)
|
|
278
|
+
out.append("Result: " + ("clean (exit 0)" if report.exit == 0 else "breaks found (exit 1)"))
|
|
279
|
+
return "\n".join(out)
|