custos-code 0.0.1__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.
@@ -0,0 +1,210 @@
1
+ """OpenTelemetry GenAI spans -> ledger.
2
+
3
+ Input is OTLP/JSON (`{"resourceSpans": [...]}`, as `otel-cli`, the collector's file exporter and
4
+ every OTLP backend emit) or a JSONL stream of individual spans. The GenAI semantic conventions
5
+ make the interesting content *opt-in*: `gen_ai.operation.name=execute_tool` spans carry the tool
6
+ name and call id, but `gen_ai.tool.call.arguments` / `.result` and the message events only exist
7
+ when the instrumentation sets `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`. A span with
8
+ no captured result is recorded with `flags.stderr_dropped`, and when *no* span in the trace
9
+ carries a result the adapter writes the `no_tool_log` META row, so outcome claims come out
10
+ `unrecorded` -- the record is known-incomplete -- rather than confirmed off a bare span status.
11
+
12
+ Span status maps to outcome: `STATUS_CODE_ERROR` is positive evidence of failure. `UNSET` is not
13
+ evidence of success, and neither is `OK` on its own -- the convention lets an exporter mark a span
14
+ OK with nothing captured, and Tier 2 needs runner output, not just a status. exit_code 0 therefore
15
+ requires a captured `process.exit_code`, or an OK status *and* a captured tool result.
16
+
17
+ Owner: Ananya.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from datetime import UTC, datetime
24
+ from typing import Any
25
+
26
+ from ..ledger import chain, redact
27
+ from ..models import EventFlags, EventKind, LedgerEvent, Session
28
+ from ..parsers import is_piped
29
+ from .state import Builder, as_dict, as_list, as_text, no_tool_log
30
+
31
+ _TOOL_ALIASES = {
32
+ "bash": "Bash",
33
+ "shell": "Bash",
34
+ "run_command": "Bash",
35
+ "terminal": "Bash",
36
+ "edit": "Edit",
37
+ "write": "Edit",
38
+ "apply_patch": "Edit",
39
+ "str_replace_editor": "Edit",
40
+ "read": "Read",
41
+ "view": "Read",
42
+ "web_search": "WebSearch",
43
+ }
44
+
45
+
46
+ def _attributes(raw: object) -> dict[str, Any]:
47
+ """OTLP attributes are [{key, value:{stringValue|intValue|...}}]; plain dicts also accepted."""
48
+ if isinstance(raw, dict):
49
+ return dict(raw)
50
+ out: dict[str, Any] = {}
51
+ for item in as_list(raw):
52
+ attr = as_dict(item)
53
+ key = str(attr.get("key", ""))
54
+ value = attr.get("value")
55
+ if not key:
56
+ continue
57
+ if isinstance(value, dict):
58
+ for field in ("stringValue", "intValue", "doubleValue", "boolValue"):
59
+ if field in value:
60
+ out[key] = int(value[field]) if field == "intValue" else value[field]
61
+ break
62
+ else:
63
+ out[key] = as_text(value.get("arrayValue") or value.get("kvlistValue") or value)
64
+ else:
65
+ out[key] = value
66
+ return out
67
+
68
+
69
+ def _nano_ts(value: object, fallback: datetime) -> datetime:
70
+ if isinstance(value, str) and value.isdigit():
71
+ value = int(value)
72
+ if isinstance(value, int | float) and value:
73
+ return datetime.fromtimestamp(float(value) / 1e9, tz=UTC)
74
+ return fallback
75
+
76
+
77
+ def _spans(payload: object) -> list[dict[str, Any]]:
78
+ """Flatten OTLP resourceSpans/scopeSpans, or accept a bare span list."""
79
+ if isinstance(payload, list):
80
+ return [as_dict(s) for s in payload]
81
+ root = as_dict(payload)
82
+ if "resourceSpans" not in root:
83
+ return [root] if root.get("spanId") or root.get("name") else []
84
+ out: list[dict[str, Any]] = []
85
+ for resource in as_list(root.get("resourceSpans")):
86
+ res = as_dict(resource)
87
+ res_attrs = _attributes(as_dict(res.get("resource")).get("attributes"))
88
+ for scope in as_list(res.get("scopeSpans")):
89
+ for span in as_list(as_dict(scope).get("spans")):
90
+ s = as_dict(span)
91
+ s["_resource"] = res_attrs
92
+ out.append(s)
93
+ return out
94
+
95
+
96
+ def _tool(attrs: dict[str, Any], span_name: str) -> str:
97
+ name = str(attrs.get("gen_ai.tool.name") or attrs.get("tool.name") or span_name.split(" ")[-1])
98
+ return _TOOL_ALIASES.get(name.lower(), name or "Tool")
99
+
100
+
101
+ def parse(path: str) -> tuple[Session, list[LedgerEvent], str | None]:
102
+ with open(path, encoding="utf-8") as fh:
103
+ text = fh.read().strip()
104
+ try:
105
+ payload: object = json.loads(text)
106
+ except json.JSONDecodeError:
107
+ payload = [json.loads(line) for line in text.splitlines() if line.strip()]
108
+ spans = sorted(_spans(payload), key=lambda s: int(s.get("startTimeUnixNano") or 0))
109
+ if not spans:
110
+ raise ValueError(f"{path}: no spans found")
111
+
112
+ first = _attributes(spans[0].get("attributes")) | as_dict(spans[0].get("_resource"))
113
+ session_id = str(
114
+ first.get("gen_ai.conversation.id")
115
+ or first.get("session.id")
116
+ or spans[0].get("traceId")
117
+ or "otel-unknown"
118
+ )
119
+ epoch = datetime.fromtimestamp(0, tz=UTC)
120
+ started = _nano_ts(spans[0].get("startTimeUnixNano"), epoch)
121
+ cwd = str(first.get("process.working_directory") or "") or None
122
+
123
+ b = Builder(session_id, cwd)
124
+ report: str | None = None
125
+ ended = started
126
+ model: str | None = None
127
+
128
+ for span in spans:
129
+ attrs = _attributes(span.get("attributes")) | as_dict(span.get("_resource"))
130
+ start = _nano_ts(span.get("startTimeUnixNano"), started)
131
+ end = _nano_ts(span.get("endTimeUnixNano"), start)
132
+ ended = max(ended, end)
133
+ model = model or (
134
+ str(attrs.get("gen_ai.request.model")) if attrs.get("gen_ai.request.model") else None
135
+ )
136
+ operation = str(attrs.get("gen_ai.operation.name") or span.get("name") or "")
137
+ status = as_dict(span.get("status"))
138
+ failed = str(status.get("code", "")).endswith("ERROR")
139
+
140
+ if operation != "execute_tool" and not str(attrs.get("gen_ai.tool.name") or ""):
141
+ # inference spans carry the model's own words: report material, never evidence
142
+ output = as_text(attrs.get("gen_ai.output.messages") or attrs.get("gen_ai.completion"))
143
+ if output:
144
+ b.store_output(b.add(kind=EventKind.TEXT, ts=end), output)
145
+ report = output
146
+ continue
147
+
148
+ tool = _tool(attrs, str(span.get("name", "")))
149
+ arguments = attrs.get("gen_ai.tool.call.arguments")
150
+ command = str(
151
+ as_dict(arguments).get("command") if isinstance(arguments, dict) else arguments or ""
152
+ )
153
+ piped = bool(command) and is_piped(command)
154
+ call_input = redact({"command": command} if command else {"arguments": as_text(arguments)})
155
+ b.add(
156
+ kind=EventKind.CALL,
157
+ ts=start,
158
+ tool=tool,
159
+ input=call_input,
160
+ flags=EventFlags(piped=piped, sidechain=bool(attrs.get("gen_ai.agent.id"))),
161
+ )
162
+
163
+ exit_raw = attrs.get("process.exit_code")
164
+ result = attrs.get("gen_ai.tool.call.result")
165
+ ok = str(status.get("code", "")).endswith("OK") and result is not None
166
+ exit_code = (
167
+ int(exit_raw) if isinstance(exit_raw, int) else (1 if failed else 0 if ok else None)
168
+ )
169
+ event = b.add(
170
+ kind=EventKind.RESULT,
171
+ ts=end,
172
+ tool=tool,
173
+ input=call_input,
174
+ exit_code=exit_code,
175
+ duration_ms=int((end - start).total_seconds() * 1000),
176
+ flags=EventFlags(
177
+ piped=piped,
178
+ error=failed,
179
+ # content capture is opt-in in the convention; say so rather than guess
180
+ stderr_dropped=result is None,
181
+ ),
182
+ )
183
+ if result is not None:
184
+ b.store_output(event, as_text(result))
185
+
186
+ results = sum(1 for e in b.events if e.kind is EventKind.RESULT)
187
+ captured = sum(1 for e in b.events if e.kind is EventKind.RESULT and not e.flags.stderr_dropped)
188
+ if results and not captured:
189
+ no_tool_log(
190
+ b,
191
+ ended,
192
+ "trace captured no gen_ai.tool.call.result: content capture is opt-in in the GenAI "
193
+ "convention, so tool outcomes are not in the record",
194
+ evidence_class="T",
195
+ )
196
+ events = chain(b.events)
197
+ meta = Session(
198
+ id=session_id,
199
+ source="otel",
200
+ agent=str(first.get("gen_ai.agent.name") or first.get("service.name") or "otel"),
201
+ model=model,
202
+ started=started,
203
+ ended=ended,
204
+ cwd=cwd,
205
+ n_events=len(events),
206
+ ledger_root_hash=events[-1].hash if events else "",
207
+ # completeness is the known weak point of trace-based evidence: score it, don't assume it
208
+ integrity_score=round(captured / results, 2) if results else 0.5,
209
+ )
210
+ return meta, events, report
@@ -0,0 +1,164 @@
1
+ """Shared state-evidence builders for the class-R adapters (Devin cloud, Copilot, any PR bot).
2
+
3
+ Class R has no tool log by default: the agent ran elsewhere and left a PR behind. What it does
4
+ leave is state -- commits, CI conclusions, and the files in a checkout -- which is the state half
5
+ of the two-evidence rule, so edit/create/commit claims are settleable here and shell claims are
6
+ not. Devin and Copilot differ only in how the report and metadata are fetched, so the evidence
7
+ side lives here once.
8
+
9
+ Owner: Ananya.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ from datetime import UTC, datetime
18
+ from typing import Any
19
+
20
+ from ..ledger import MAX_OUTPUT_BYTES, redact
21
+ from ..models import EventFlags, EventKind, LedgerEvent
22
+
23
+ FAILED_CONCLUSIONS = frozenset(
24
+ {"failure", "timed_out", "cancelled", "action_required", "startup_failure"}
25
+ )
26
+
27
+
28
+ def as_ts(value: object, fallback: datetime) -> datetime:
29
+ if isinstance(value, str):
30
+ try:
31
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
32
+ except ValueError:
33
+ return fallback
34
+ if isinstance(value, int | float):
35
+ return datetime.fromtimestamp(float(value), tz=UTC)
36
+ return fallback
37
+
38
+
39
+ def as_dict(value: object) -> dict[str, Any]:
40
+ return dict(value) if isinstance(value, dict) else {}
41
+
42
+
43
+ def as_list(value: object) -> list[Any]:
44
+ return list(value) if isinstance(value, list) else []
45
+
46
+
47
+ def as_text(value: object) -> str:
48
+ if isinstance(value, str):
49
+ return value
50
+ if value is None:
51
+ return ""
52
+ return json.dumps(value, indent=2, sort_keys=True, default=str)
53
+
54
+
55
+ def abs_path(path: str, root: str | None) -> str:
56
+ return path if os.path.isabs(path) or not root else os.path.normpath(os.path.join(root, path))
57
+
58
+
59
+ class Builder:
60
+ """Accumulates ledger events for one session, numbering and hashing output as it goes."""
61
+
62
+ def __init__(self, session_id: str, cwd: str | None) -> None:
63
+ self.events: list[LedgerEvent] = []
64
+ self.seq = 0
65
+ self.session_id = session_id
66
+ self.cwd = cwd
67
+
68
+ def add(self, **fields: Any) -> LedgerEvent:
69
+ event = LedgerEvent(seq=self.seq, session_id=self.session_id, cwd=self.cwd, **fields)
70
+ self.events.append(event)
71
+ self.seq += 1
72
+ return event
73
+
74
+ def store_output(self, event: LedgerEvent, text: str) -> None:
75
+ blob = str(redact(text))
76
+ raw = blob.encode()
77
+ event.output_hash = hashlib.sha256(raw).hexdigest()
78
+ event.output = raw[:MAX_OUTPUT_BYTES].decode(errors="ignore")
79
+ event.flags.truncated = event.flags.truncated or len(raw) > MAX_OUTPUT_BYTES
80
+
81
+
82
+ def commits(b: Builder, git: dict[str, Any], fallback: datetime) -> datetime:
83
+ """Commits satisfy invariant 5 for edit/create claims: the transcript alone never could."""
84
+ root = str(git.get("root") or b.cwd or "") or None
85
+ ts = fallback
86
+ for raw in as_list(git.get("commits")):
87
+ commit = as_dict(raw)
88
+ ts = as_ts(commit.get("ts") or commit.get("date"), ts)
89
+ files = [abs_path(str(f), root) for f in as_list(commit.get("files"))]
90
+ event = b.add(
91
+ kind=EventKind.RESULT,
92
+ ts=ts,
93
+ tool="Git",
94
+ exit_code=0,
95
+ paths=files,
96
+ input=redact(
97
+ {"sha": str(commit.get("sha", "")), "subject": str(commit.get("subject", ""))}
98
+ ),
99
+ )
100
+ b.store_output(
101
+ event, as_text(commit.get("stat") or commit.get("body") or commit.get("subject"))
102
+ )
103
+ return ts
104
+
105
+
106
+ def checks(b: Builder, runs: list[Any], fallback: datetime) -> datetime:
107
+ """CI runs are the outcome evidence class R has; a failed conclusion is positive evidence."""
108
+ ts = fallback
109
+ for raw in runs:
110
+ check = as_dict(raw)
111
+ ts = as_ts(check.get("completed_at") or check.get("started_at"), ts)
112
+ conclusion = str(check.get("conclusion") or check.get("status") or "").lower()
113
+ failed = conclusion in FAILED_CONCLUSIONS
114
+ started = as_ts(check.get("started_at"), ts)
115
+ duration = int((ts - started).total_seconds() * 1000) if ts > started else None
116
+ meta = redact({"name": str(check.get("name", "")), "url": str(check.get("url", ""))})
117
+ b.add(kind=EventKind.CALL, ts=started, tool="CI", input=meta)
118
+ event = b.add(
119
+ kind=EventKind.RESULT,
120
+ ts=ts,
121
+ tool="CI",
122
+ duration_ms=duration,
123
+ input=meta,
124
+ exit_code=0 if conclusion == "success" else 1 if failed else None,
125
+ flags=EventFlags(error=failed),
126
+ )
127
+ b.store_output(event, as_text(check.get("output") or check.get("logs") or conclusion))
128
+ return ts
129
+
130
+
131
+ def checkout(b: Builder, probes: dict[str, Any], fallback: datetime) -> None:
132
+ """Filesystem probes taken after the session: `exists: false` is positive evidence of absence."""
133
+ root = str(probes.get("root") or b.cwd or "") or None
134
+ for raw in as_list(probes.get("files")):
135
+ probe = as_dict(raw)
136
+ path = abs_path(str(probe.get("path", "")), root)
137
+ if not path:
138
+ continue
139
+ exists = bool(probe.get("exists", True))
140
+ event = b.add(
141
+ kind=EventKind.RESULT,
142
+ ts=as_ts(probe.get("ts"), fallback),
143
+ tool="Read",
144
+ paths=[path],
145
+ exit_code=0 if exists else 1,
146
+ flags=EventFlags(error=not exists),
147
+ input={"path": path, "probe": "stat"},
148
+ )
149
+ b.store_output(
150
+ event,
151
+ json.dumps(
152
+ {"exists": exists, "sha256": probe.get("sha256"), "bytes": probe.get("bytes")},
153
+ sort_keys=True,
154
+ ),
155
+ )
156
+
157
+
158
+ def no_tool_log(b: Builder, ts: datetime, note: str, evidence_class: str = "R") -> LedgerEvent:
159
+ """Record the instrumentation gap explicitly, so Tier 1/2 answer `unrecorded`, not `unwitnessed`."""
160
+ return b.add(
161
+ kind=EventKind.META,
162
+ ts=ts,
163
+ input={"event": "no_tool_log", "class": evidence_class, "note": note},
164
+ )