clink-cli 0.2.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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- utils/token_utils.py +54 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Validate Claude generation streams without publishing tool or intermediate text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .base import (
|
|
13
|
+
MAX_METADATA_NUMBER,
|
|
14
|
+
BaseParser,
|
|
15
|
+
ParsedCLIResponse,
|
|
16
|
+
ParserError,
|
|
17
|
+
bounded_number,
|
|
18
|
+
cli_failure,
|
|
19
|
+
load_json,
|
|
20
|
+
model_identifier,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
MAX_EVENTS = 16384
|
|
24
|
+
MAX_STREAM_BYTES = 10 * 1024 * 1024
|
|
25
|
+
_SESSION = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{0,127}\Z")
|
|
26
|
+
_NOTICE_SUBTYPES = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"commands_changed",
|
|
29
|
+
"session_title_changed",
|
|
30
|
+
"informational",
|
|
31
|
+
"instruction_size_warning",
|
|
32
|
+
"api_retry",
|
|
33
|
+
"thinking_tokens",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
_SYSTEM_SUBTYPES = (
|
|
37
|
+
frozenset(
|
|
38
|
+
{"init", "status", "compact_boundary", "hook_started", "hook_progress", "hook_response", "permission_denied"}
|
|
39
|
+
)
|
|
40
|
+
| _NOTICE_SUBTYPES
|
|
41
|
+
)
|
|
42
|
+
_RETRY_ERRORS = frozenset(
|
|
43
|
+
{"overloaded", "rate_limit", "authentication_failed", "server_error", "cloud_credential_error", "unknown"}
|
|
44
|
+
)
|
|
45
|
+
_DIAGNOSTIC_REASONS = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"stream_bounds",
|
|
48
|
+
"json_invalid",
|
|
49
|
+
"event_envelope",
|
|
50
|
+
"unknown_event",
|
|
51
|
+
"unknown_system_subtype",
|
|
52
|
+
"notice_shape",
|
|
53
|
+
"notice_stop",
|
|
54
|
+
"session_invalid",
|
|
55
|
+
"session_mismatch",
|
|
56
|
+
"init_required",
|
|
57
|
+
"duplicate_init",
|
|
58
|
+
"init_model_invalid",
|
|
59
|
+
"message_shape",
|
|
60
|
+
"native_failure",
|
|
61
|
+
"permission_denied",
|
|
62
|
+
"rate_limited",
|
|
63
|
+
"terminal_shape",
|
|
64
|
+
"empty_result",
|
|
65
|
+
"post_terminal",
|
|
66
|
+
"terminal_missing",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
_DIAGNOSTIC_PREFIX = "Claude native generation output violates its protocol"
|
|
70
|
+
_DIAGNOSTIC = re.compile(re.escape(_DIAGNOSTIC_PREFIX) + r" \[reason=([a-z_]+);event_index=(null|[1-9][0-9]{0,4})\]\Z")
|
|
71
|
+
PARSER_CONTRACT = (
|
|
72
|
+
"claude-native-generation-stream-json/v3;strict-utf8-jsonl;no-duplicate-keys-or-nonfinite-numbers;"
|
|
73
|
+
"max-events=16384;max-stream-bytes=10485760;one-init-before-messages;one-session;one-last-result;"
|
|
74
|
+
"answer=exact-nonblank-successful-terminal-result.result;primary-model=system.init.model;"
|
|
75
|
+
"modelUsage-is-not-primary-model;errors-and-permission-denials-refuse;no-raw-metadata;"
|
|
76
|
+
"six-validated-operational-system-notices;thinking-progress-after-init;only-validated-api-retry-error-is-recoverable;"
|
|
77
|
+
"informational-stop-and-prompt-submit-outcome-refuse;closed-diagnostic-reason-and-event-index;"
|
|
78
|
+
"identity=sha256(canonical-json(contract,source-sha256(claude_native_generation.py,base.py)))"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _ClaudeParseError(ParserError):
|
|
83
|
+
def __init__(self, reason: str, event_index: int | None = None, *, code: str = "protocol_error"):
|
|
84
|
+
self.reason = reason
|
|
85
|
+
index = "null" if event_index is None else str(event_index)
|
|
86
|
+
super().__init__(f"{_DIAGNOSTIC_PREFIX} [reason={reason};event_index={index}]", code=code)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _protocol(reason: str = "event_envelope") -> ParserError:
|
|
90
|
+
return _ClaudeParseError(reason)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def protocol_diagnostic(message: object) -> dict[str, Any] | None:
|
|
94
|
+
"""Extract only a closed Claude diagnostic, never native output or arbitrary errors."""
|
|
95
|
+
match = _DIAGNOSTIC.fullmatch(message) if isinstance(message, str) else None
|
|
96
|
+
if match is None or match[1] not in _DIAGNOSTIC_REASONS:
|
|
97
|
+
return None
|
|
98
|
+
index = None if match[2] == "null" else int(match[2])
|
|
99
|
+
if index is not None and index > MAX_EVENTS:
|
|
100
|
+
return None
|
|
101
|
+
return {"reason": match[1], "event_index": index}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def parser_contract_digest() -> str:
|
|
105
|
+
sources = {}
|
|
106
|
+
try:
|
|
107
|
+
for name in ("claude_native_generation.py", "base.py"):
|
|
108
|
+
with Path(__file__).with_name(name).open("rb") as stream:
|
|
109
|
+
content = stream.read(1024 * 1024 + 1)
|
|
110
|
+
if len(content) > 1024 * 1024:
|
|
111
|
+
raise _protocol("stream_bounds")
|
|
112
|
+
sources[name] = hashlib.sha256(content).hexdigest()
|
|
113
|
+
except OSError:
|
|
114
|
+
raise _protocol("stream_bounds") from None
|
|
115
|
+
identity = json.dumps(
|
|
116
|
+
{"contract": PARSER_CONTRACT, "source_sha256": sources}, sort_keys=True, separators=(",", ":")
|
|
117
|
+
)
|
|
118
|
+
return hashlib.sha256(identity.encode()).hexdigest()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _valid_text(value: object) -> bool:
|
|
122
|
+
if not isinstance(value, str):
|
|
123
|
+
return False
|
|
124
|
+
try:
|
|
125
|
+
value.encode("utf-8")
|
|
126
|
+
except UnicodeError:
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _validate_json(value: Any) -> None:
|
|
132
|
+
pending = [value]
|
|
133
|
+
while pending:
|
|
134
|
+
part = pending.pop()
|
|
135
|
+
if isinstance(part, dict):
|
|
136
|
+
if not all(_valid_text(key) for key in part):
|
|
137
|
+
raise _protocol("json_invalid")
|
|
138
|
+
pending.extend(part.values())
|
|
139
|
+
elif isinstance(part, list):
|
|
140
|
+
pending.extend(part)
|
|
141
|
+
elif isinstance(part, str) and not _valid_text(part):
|
|
142
|
+
raise _protocol("json_invalid")
|
|
143
|
+
elif isinstance(part, float) and not math.isfinite(part):
|
|
144
|
+
raise _protocol("json_invalid")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _integer(value: object, minimum: int = 0, maximum: int = MAX_METADATA_NUMBER) -> bool:
|
|
148
|
+
return type(value) is int and minimum <= value <= maximum
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _notice(event: dict[str, Any]) -> None:
|
|
152
|
+
subtype = event["subtype"]
|
|
153
|
+
if subtype == "session_title_changed":
|
|
154
|
+
valid = isinstance(event.get("title"), str) and bool(event["title"].strip())
|
|
155
|
+
elif subtype == "commands_changed":
|
|
156
|
+
commands = event.get("commands")
|
|
157
|
+
valid = isinstance(commands, list) and all(
|
|
158
|
+
isinstance(command, dict)
|
|
159
|
+
and all(isinstance(command.get(field), str) for field in ("name", "description", "argumentHint"))
|
|
160
|
+
and (
|
|
161
|
+
"aliases" not in command
|
|
162
|
+
or isinstance(command["aliases"], list)
|
|
163
|
+
and all(isinstance(alias, str) for alias in command["aliases"])
|
|
164
|
+
)
|
|
165
|
+
and ("builtin" not in command or type(command["builtin"]) is bool)
|
|
166
|
+
for command in commands
|
|
167
|
+
)
|
|
168
|
+
elif subtype == "informational":
|
|
169
|
+
level = event.get("level")
|
|
170
|
+
valid = (
|
|
171
|
+
isinstance(event.get("content"), str)
|
|
172
|
+
and isinstance(level, str)
|
|
173
|
+
and level in {"info", "notice", "suggestion", "warning"}
|
|
174
|
+
and ("tool_use_id" not in event or isinstance(event["tool_use_id"], str))
|
|
175
|
+
and ("prevent_continuation" not in event or type(event["prevent_continuation"]) is bool)
|
|
176
|
+
)
|
|
177
|
+
elif subtype == "thinking_tokens":
|
|
178
|
+
valid = all(
|
|
179
|
+
_integer(event.get(field), -MAX_METADATA_NUMBER) for field in ("estimated_tokens", "estimated_tokens_delta")
|
|
180
|
+
) and ("user_message_uuid" not in event or isinstance(event["user_message_uuid"], str))
|
|
181
|
+
elif subtype == "instruction_size_warning":
|
|
182
|
+
valid = all(_integer(event.get(field)) for field in ("total_chars", "total_limit_chars", "file_count")) and (
|
|
183
|
+
"largest_chars" not in event or _integer(event["largest_chars"])
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
error = event.get("error")
|
|
187
|
+
response = event.get("no_response")
|
|
188
|
+
valid = (
|
|
189
|
+
_integer(event.get("attempt"), 1)
|
|
190
|
+
and _integer(event.get("max_retries"))
|
|
191
|
+
and bounded_number(event.get("retry_delay_ms")) is not None
|
|
192
|
+
and "error_status" in event
|
|
193
|
+
and (event["error_status"] is None or _integer(event["error_status"]))
|
|
194
|
+
and isinstance(error, str)
|
|
195
|
+
and error in _RETRY_ERRORS
|
|
196
|
+
and (
|
|
197
|
+
"no_response" not in event
|
|
198
|
+
or isinstance(response, dict)
|
|
199
|
+
and all(bounded_number(response.get(field)) is not None for field in ("waited_ms", "retry_wait_ms"))
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
if not valid:
|
|
203
|
+
raise _protocol("notice_shape")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _failure(event: dict[str, Any], stderr: str, *, validated_retry: bool = False) -> None:
|
|
207
|
+
if "is_error" in event and type(event["is_error"]) is not bool:
|
|
208
|
+
raise _protocol()
|
|
209
|
+
if "errors" in event and not isinstance(event["errors"], list):
|
|
210
|
+
raise _protocol()
|
|
211
|
+
status = event.get("status")
|
|
212
|
+
subtype = event.get("subtype")
|
|
213
|
+
if status is not None and not isinstance(status, str):
|
|
214
|
+
raise _protocol()
|
|
215
|
+
if subtype is not None and not isinstance(subtype, str):
|
|
216
|
+
raise _protocol()
|
|
217
|
+
if (
|
|
218
|
+
event.get("type") == "error"
|
|
219
|
+
or event.get("is_error") is True
|
|
220
|
+
or (event.get("error") is not None and not validated_retry)
|
|
221
|
+
or event.get("errors")
|
|
222
|
+
or status in {"error", "failed", "cancelled", "aborted", "interrupted"}
|
|
223
|
+
or (isinstance(subtype, str) and subtype.startswith("error"))
|
|
224
|
+
):
|
|
225
|
+
raise cli_failure(event, stderr, message="Claude native generation did not complete", code="protocol_error")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ClaudeNativeGenerationParser(BaseParser):
|
|
229
|
+
"""Publish only one validated final result, preserving its exact text."""
|
|
230
|
+
|
|
231
|
+
name = "claude_native_generation_jsonl"
|
|
232
|
+
|
|
233
|
+
def parse(self, stdout: str, stderr: str = "") -> ParsedCLIResponse:
|
|
234
|
+
if not _valid_text(stdout) or not _valid_text(stderr):
|
|
235
|
+
raise _protocol("json_invalid")
|
|
236
|
+
if len(stdout.encode()) > MAX_STREAM_BYTES:
|
|
237
|
+
raise _protocol("stream_bounds")
|
|
238
|
+
lines = [line for line in stdout.split("\n") if line.strip(" \t\r")]
|
|
239
|
+
if len(lines) > MAX_EVENTS:
|
|
240
|
+
raise _protocol("stream_bounds")
|
|
241
|
+
if not lines:
|
|
242
|
+
failure = cli_failure(stderr, code="protocol_error")
|
|
243
|
+
raise _ClaudeParseError("terminal_missing", code=failure.code)
|
|
244
|
+
initialized = False
|
|
245
|
+
session: str | None = None
|
|
246
|
+
actual_model: str | None = None
|
|
247
|
+
terminal: dict[str, Any] | None = None
|
|
248
|
+
for event_index, line in enumerate(lines, 1):
|
|
249
|
+
try:
|
|
250
|
+
try:
|
|
251
|
+
event = load_json(line)
|
|
252
|
+
except ParserError:
|
|
253
|
+
raise _protocol("json_invalid") from None
|
|
254
|
+
_validate_json(event)
|
|
255
|
+
if terminal is not None:
|
|
256
|
+
raise _protocol("post_terminal")
|
|
257
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str):
|
|
258
|
+
raise _protocol("event_envelope")
|
|
259
|
+
kind = event["type"]
|
|
260
|
+
subtype = event.get("subtype")
|
|
261
|
+
notice = kind == "system" and isinstance(subtype, str) and subtype in _NOTICE_SUBTYPES
|
|
262
|
+
if "permission_denials" in event:
|
|
263
|
+
denials = event["permission_denials"]
|
|
264
|
+
if not isinstance(denials, list):
|
|
265
|
+
raise _protocol("terminal_shape" if kind == "result" else "event_envelope")
|
|
266
|
+
if denials:
|
|
267
|
+
raise _ClaudeParseError("permission_denied", code="permission_needed")
|
|
268
|
+
if notice:
|
|
269
|
+
_notice(event)
|
|
270
|
+
_failure(event, stderr, validated_retry=notice and subtype == "api_retry")
|
|
271
|
+
observed = event.get("session_id")
|
|
272
|
+
if observed is not None:
|
|
273
|
+
if not isinstance(observed, str) or not _SESSION.fullmatch(observed):
|
|
274
|
+
raise _protocol("session_invalid")
|
|
275
|
+
if session is not None and session != observed:
|
|
276
|
+
raise _protocol("session_mismatch")
|
|
277
|
+
session = observed
|
|
278
|
+
if notice and observed is None:
|
|
279
|
+
raise _protocol("session_invalid")
|
|
280
|
+
if kind == "system":
|
|
281
|
+
if subtype not in _SYSTEM_SUBTYPES:
|
|
282
|
+
raise _protocol("unknown_system_subtype")
|
|
283
|
+
if subtype == "permission_denied":
|
|
284
|
+
raise _ClaudeParseError("permission_denied", code="permission_needed")
|
|
285
|
+
if subtype == "thinking_tokens" and not initialized:
|
|
286
|
+
raise _protocol("init_required")
|
|
287
|
+
if subtype == "informational" and (
|
|
288
|
+
event.get("prevent_continuation") is True or "prompt_submit_outcome" in event
|
|
289
|
+
):
|
|
290
|
+
raise _protocol("notice_stop")
|
|
291
|
+
if subtype == "init":
|
|
292
|
+
if initialized:
|
|
293
|
+
raise _protocol("duplicate_init")
|
|
294
|
+
actual_model = model_identifier(event.get("model"))
|
|
295
|
+
if observed is None:
|
|
296
|
+
raise _protocol("session_invalid")
|
|
297
|
+
if actual_model is None:
|
|
298
|
+
raise _protocol("init_model_invalid")
|
|
299
|
+
initialized = True
|
|
300
|
+
elif kind in {"assistant", "user"}:
|
|
301
|
+
if not initialized:
|
|
302
|
+
raise _protocol("init_required")
|
|
303
|
+
if not isinstance(event.get("message"), dict):
|
|
304
|
+
raise _protocol("message_shape")
|
|
305
|
+
blocks = event["message"].get("content")
|
|
306
|
+
if not isinstance(blocks, list):
|
|
307
|
+
raise _protocol("message_shape")
|
|
308
|
+
for block in blocks:
|
|
309
|
+
if not isinstance(block, dict) or not isinstance(block.get("type"), str):
|
|
310
|
+
raise _protocol("message_shape")
|
|
311
|
+
_failure(block, stderr)
|
|
312
|
+
if block["type"] == "text" and not isinstance(block.get("text"), str):
|
|
313
|
+
raise _protocol("message_shape")
|
|
314
|
+
elif kind == "result":
|
|
315
|
+
if not initialized:
|
|
316
|
+
raise _protocol("init_required")
|
|
317
|
+
if (
|
|
318
|
+
observed is None
|
|
319
|
+
or event.get("subtype") != "success"
|
|
320
|
+
or event.get("is_error") is not False
|
|
321
|
+
or event.get("status", "success") not in {"success", "completed"}
|
|
322
|
+
or not isinstance(event.get("result"), str)
|
|
323
|
+
):
|
|
324
|
+
raise _protocol("terminal_shape")
|
|
325
|
+
if not event["result"].strip():
|
|
326
|
+
raise _ClaudeParseError("empty_result", code="empty_response")
|
|
327
|
+
terminal = event
|
|
328
|
+
elif kind == "rate_limit_event":
|
|
329
|
+
if not initialized:
|
|
330
|
+
raise _protocol("init_required")
|
|
331
|
+
if not isinstance(event.get("rate_limit_info"), dict):
|
|
332
|
+
raise _protocol("event_envelope")
|
|
333
|
+
if event["rate_limit_info"].get("status") == "rejected":
|
|
334
|
+
raise _ClaudeParseError("rate_limited", code="rate_limited")
|
|
335
|
+
else:
|
|
336
|
+
raise _protocol("unknown_event")
|
|
337
|
+
except ParserError as error:
|
|
338
|
+
reason = error.reason if isinstance(error, _ClaudeParseError) else "native_failure"
|
|
339
|
+
raise _ClaudeParseError(reason, event_index, code=error.code) from None
|
|
340
|
+
if terminal is None:
|
|
341
|
+
failure = cli_failure(stderr, code="protocol_error")
|
|
342
|
+
raise _ClaudeParseError("terminal_missing", code=failure.code)
|
|
343
|
+
return ParsedCLIResponse(
|
|
344
|
+
content=terminal["result"],
|
|
345
|
+
metadata={"actual_model": actual_model, "tool_error_count": 0, "permission_denial_count": 0},
|
|
346
|
+
)
|
clink/parsers/codex.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Parser for Codex CLI JSONL output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import (
|
|
8
|
+
MAX_SOURCE_URLS,
|
|
9
|
+
BaseParser,
|
|
10
|
+
ParsedCLIResponse,
|
|
11
|
+
ParserError,
|
|
12
|
+
bounded_counts,
|
|
13
|
+
cli_failure,
|
|
14
|
+
load_json,
|
|
15
|
+
model_identifier,
|
|
16
|
+
source_url,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_USAGE_FIELDS = (
|
|
20
|
+
"input_tokens",
|
|
21
|
+
"cached_input_tokens",
|
|
22
|
+
"cache_write_input_tokens",
|
|
23
|
+
"output_tokens",
|
|
24
|
+
"total_tokens",
|
|
25
|
+
"reasoning_output_tokens",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CodexJSONLParser(BaseParser):
|
|
30
|
+
"""Parse stdout emitted by `codex exec --json`."""
|
|
31
|
+
|
|
32
|
+
name = "codex_jsonl"
|
|
33
|
+
|
|
34
|
+
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
|
35
|
+
lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()]
|
|
36
|
+
agent_messages: list[str] = []
|
|
37
|
+
terminal: dict[str, Any] | None = None
|
|
38
|
+
search_performed = False
|
|
39
|
+
source_urls: list[str] = []
|
|
40
|
+
|
|
41
|
+
for line in lines:
|
|
42
|
+
event = load_json(line, allow_codex_web_search_ids=True)
|
|
43
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str) or not event["type"]:
|
|
44
|
+
raise ParserError("Codex CLI JSONL events must be typed objects")
|
|
45
|
+
if "status" in event and not isinstance(event["status"], str):
|
|
46
|
+
raise ParserError("Codex CLI event status must be textual")
|
|
47
|
+
|
|
48
|
+
event_type = event["type"]
|
|
49
|
+
if (
|
|
50
|
+
event_type == "error"
|
|
51
|
+
or event_type.endswith(".failed")
|
|
52
|
+
or event_type in {"turn.cancelled", "turn.aborted", "turn.interrupted"}
|
|
53
|
+
or event.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
54
|
+
or event.get("error") is not None
|
|
55
|
+
or event.get("errors")
|
|
56
|
+
or event.get("is_error", False) is not False
|
|
57
|
+
):
|
|
58
|
+
raise cli_failure(event, stderr, message="Codex CLI reported a failed turn")
|
|
59
|
+
if terminal is not None:
|
|
60
|
+
raise ParserError("Codex CLI emitted events after its terminal result")
|
|
61
|
+
|
|
62
|
+
if event_type.startswith("item."):
|
|
63
|
+
item = event.get("item")
|
|
64
|
+
if not isinstance(item, dict) or not isinstance(item.get("type"), str) or not item["type"]:
|
|
65
|
+
raise ParserError("Codex CLI item events must contain typed item objects")
|
|
66
|
+
if "status" in item and not isinstance(item["status"], str):
|
|
67
|
+
raise ParserError("Codex CLI item status must be textual")
|
|
68
|
+
if (
|
|
69
|
+
item["type"] == "error"
|
|
70
|
+
or item.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
71
|
+
or item.get("error") is not None
|
|
72
|
+
or item.get("errors")
|
|
73
|
+
or item.get("is_error", False) is not False
|
|
74
|
+
):
|
|
75
|
+
raise cli_failure(item, stderr, message="Codex CLI reported a failed item")
|
|
76
|
+
|
|
77
|
+
if event_type == "item.completed":
|
|
78
|
+
if item["type"] == "web_search":
|
|
79
|
+
performed, urls = self._search_evidence(item, stderr)
|
|
80
|
+
search_performed = search_performed or performed
|
|
81
|
+
for url in urls:
|
|
82
|
+
if url not in source_urls and len(source_urls) < MAX_SOURCE_URLS:
|
|
83
|
+
source_urls.append(url)
|
|
84
|
+
if item["type"] == "command_execution" and "exit_code" in item:
|
|
85
|
+
exit_code = item["exit_code"]
|
|
86
|
+
if not isinstance(exit_code, int) or isinstance(exit_code, bool):
|
|
87
|
+
raise ParserError("Codex CLI command exit code must be an integer")
|
|
88
|
+
if exit_code != 0:
|
|
89
|
+
raise cli_failure(item, stderr, message="Codex CLI command execution failed")
|
|
90
|
+
if item["type"] == "agent_message":
|
|
91
|
+
text = item.get("text")
|
|
92
|
+
if not isinstance(text, str) or not text.strip():
|
|
93
|
+
raise ParserError("Codex CLI agent_message must contain nonempty text")
|
|
94
|
+
agent_messages.append(text.strip())
|
|
95
|
+
elif event_type == "turn.completed":
|
|
96
|
+
if event.get("status", "completed") not in ("completed", "success"):
|
|
97
|
+
raise ParserError("Codex CLI terminal status did not indicate success")
|
|
98
|
+
if "usage" in event and not isinstance(event["usage"], dict):
|
|
99
|
+
raise ParserError("Codex CLI usage must be an object")
|
|
100
|
+
terminal = event
|
|
101
|
+
|
|
102
|
+
if terminal is None:
|
|
103
|
+
raise cli_failure(stderr, message="Codex CLI JSONL output did not include turn.completed")
|
|
104
|
+
if not agent_messages:
|
|
105
|
+
raise cli_failure(
|
|
106
|
+
stderr,
|
|
107
|
+
message="Codex CLI JSONL output did not include an agent_message item",
|
|
108
|
+
code="empty_response",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
content = "\n\n".join(agent_messages).strip()
|
|
112
|
+
metadata: dict[str, Any] = {}
|
|
113
|
+
usage = bounded_counts(terminal.get("usage"), _USAGE_FIELDS)
|
|
114
|
+
if usage:
|
|
115
|
+
metadata["usage"] = usage
|
|
116
|
+
actual_model = model_identifier(terminal.get("model"))
|
|
117
|
+
if actual_model:
|
|
118
|
+
metadata["actual_model"] = actual_model
|
|
119
|
+
if search_performed:
|
|
120
|
+
metadata["search_performed"] = True
|
|
121
|
+
metadata["source_urls"] = source_urls
|
|
122
|
+
|
|
123
|
+
return ParsedCLIResponse(content=content, metadata=metadata)
|
|
124
|
+
|
|
125
|
+
def _search_evidence(self, item: dict[str, Any], stderr: str) -> tuple[bool, list[str]]:
|
|
126
|
+
"""Read only the source-tested flat text_result URL shape from returned results."""
|
|
127
|
+
|
|
128
|
+
action = item.get("action")
|
|
129
|
+
results = item.get("results")
|
|
130
|
+
if "query" in item and not isinstance(item["query"], str):
|
|
131
|
+
raise ParserError("Codex CLI web search query must be textual")
|
|
132
|
+
if action is not None and not isinstance(action, dict):
|
|
133
|
+
raise ParserError("Codex CLI web search action must be an object")
|
|
134
|
+
action_type = action.get("type") if isinstance(action, dict) else None
|
|
135
|
+
if isinstance(action, dict):
|
|
136
|
+
if not isinstance(action_type, str):
|
|
137
|
+
raise ParserError("Codex CLI web search action must be typed")
|
|
138
|
+
for field in ("query", "url", "pattern"):
|
|
139
|
+
if action.get(field) is not None and not isinstance(action[field], str):
|
|
140
|
+
raise ParserError("Codex CLI web search action text fields must be textual")
|
|
141
|
+
queries = action.get("queries")
|
|
142
|
+
if queries is not None and not (
|
|
143
|
+
isinstance(queries, list) and all(isinstance(query, str) for query in queries)
|
|
144
|
+
):
|
|
145
|
+
raise ParserError("Codex CLI web search queries must be an array of text")
|
|
146
|
+
if results is None:
|
|
147
|
+
return False, []
|
|
148
|
+
if not isinstance(results, list):
|
|
149
|
+
raise ParserError("Codex CLI web search results must be an array")
|
|
150
|
+
|
|
151
|
+
urls: list[str] = []
|
|
152
|
+
recognized = not results
|
|
153
|
+
for result in results:
|
|
154
|
+
if not isinstance(result, dict):
|
|
155
|
+
continue
|
|
156
|
+
if (
|
|
157
|
+
result.get("type") in ("error", "failed")
|
|
158
|
+
or result.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
159
|
+
or result.get("error") is not None
|
|
160
|
+
or result.get("errors")
|
|
161
|
+
or (result.get("is_error") is not None and result["is_error"] is not False)
|
|
162
|
+
):
|
|
163
|
+
raise cli_failure(result, stderr, message="Codex CLI web search returned a failed result")
|
|
164
|
+
if result.get("type") != "text_result":
|
|
165
|
+
continue
|
|
166
|
+
url = source_url(result.get("url"))
|
|
167
|
+
if url:
|
|
168
|
+
recognized = True
|
|
169
|
+
if url not in urls and len(urls) < MAX_SOURCE_URLS:
|
|
170
|
+
urls.append(url)
|
|
171
|
+
known_action = action_type in ("search", "open_page", "find_in_page")
|
|
172
|
+
return action_type == "search" and recognized, urls if known_action else []
|