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,367 @@
|
|
|
1
|
+
"""Validate native generation telemetry without publishing operational output."""
|
|
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 MAX_METADATA_NUMBER, BaseParser, ParsedCLIResponse, ParserError, cli_failure, load_json
|
|
13
|
+
|
|
14
|
+
MAX_EVENTS = 16384
|
|
15
|
+
_MAX_SOURCE_BYTES = 1024 * 1024
|
|
16
|
+
_USAGE_REQUIRED = frozenset({"input_tokens", "cached_input_tokens", "output_tokens", "reasoning_output_tokens"})
|
|
17
|
+
_USAGE_OPTIONAL = frozenset({"cache_write_input_tokens"})
|
|
18
|
+
_ITEM_EVENTS = frozenset({"item.started", "item.updated", "item.completed"})
|
|
19
|
+
_TOOL_ITEMS = frozenset({"command_execution", "file_change", "mcp_tool_call", "collab_tool_call"})
|
|
20
|
+
_STATUSES = frozenset({"in_progress", "completed", "failed"})
|
|
21
|
+
_SESSION = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\Z")
|
|
22
|
+
_EXEC_POLICY_DENIAL = re.compile(
|
|
23
|
+
r"`(?P<command>[^`\r\n]+)` rejected: policy forbids commands starting with `(?P<prefix>[^`\r\n]+)`\Z"
|
|
24
|
+
)
|
|
25
|
+
_COMMAND_APPROVAL_DENIALS = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"approval required by policy, but AskForApproval is set to Never",
|
|
28
|
+
"approval required by policy, but AskForApproval::Granular.sandbox_approval is false",
|
|
29
|
+
"approval required by policy rule, but AskForApproval::Granular.rules is false",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
_MCP_APPROVAL_DENIALS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"MCP tool call requires approval, but approval policy is never",
|
|
35
|
+
"MCP tool call cancelled: interactive approval is unavailable in exec mode",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# This independent contract binds only the new profile. The service validates
|
|
40
|
+
# the private final-message file separately; streamed text is never an answer.
|
|
41
|
+
PARSER_CONTRACT = (
|
|
42
|
+
"codex-native-generation-jsonl/v1\n"
|
|
43
|
+
"source=codex-6ff1e1ca3b30fc8b914720681fcf5fe62e95f856;max-events=16384;jsonl-newline=LF\n"
|
|
44
|
+
"strict-json:no-duplicate-keys-except-pinned-direct-web-search-ids,no-nonfinite-numbers,utf8-text\n"
|
|
45
|
+
"thread.started:{type,thread_id:uuid};turn.started:{type};one-thread,one-turn,in-order\n"
|
|
46
|
+
"item.started|updated|completed:{type,item};item:{id:nonblank-text,type:known,typed-payload}\n"
|
|
47
|
+
"known-items=agent_message,reasoning,command_execution,file_change,mcp_tool_call,collab_tool_call,"
|
|
48
|
+
"web_search,todo_list,error;unknown-fields-rejected;opaque-JsonValue-subtrees-preserved-only-during-validation\n"
|
|
49
|
+
"lifecycle:no-reused-completed-id,no-type-change,no-duplicate-start,no-incomplete-observed-items;"
|
|
50
|
+
"completed-without-start-allowed;pre-turn-completed-error-warnings-allowed\n"
|
|
51
|
+
"final:one-last-turn.completed:{type,usage};no-stream-message-required;content-always-empty;"
|
|
52
|
+
"private-final-file-validation-owned-by-service;no-stream-finalfile-equality\n"
|
|
53
|
+
"usage:required=input_tokens,cached_input_tokens,output_tokens,reasoning_output_tokens;"
|
|
54
|
+
"optional=cache_write_input_tokens;nonnegative-safe-json-integers\n"
|
|
55
|
+
"recoverable-completed-tool-failures-and-item-errors-counted;"
|
|
56
|
+
"exact-source-supported-command-and-MCP-refusals=permission_needed\n"
|
|
57
|
+
"error:{type,message:utf8};turn.failed:{type,error:{message:utf8}};"
|
|
58
|
+
"failed,unknown,malformed,incomplete=protocol_error;known-auth-and-rate-diagnostics-safe-codes\n"
|
|
59
|
+
"metadata:usage,actual_model=null,tool_error_count,permission_denial_count;no-identities-or-bodies\n"
|
|
60
|
+
"identity:sha256(canonical-json(contract,source_sha256(codex_native_generation.py,base.py)));"
|
|
61
|
+
"source-limit=1048576-bytes-each\n"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _protocol_error(message: str = "Codex native generation output violates its protocol") -> ParserError:
|
|
66
|
+
return ParserError(message, code="protocol_error")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def parser_contract_digest() -> str:
|
|
70
|
+
"""Bind the native generation contract and its parser dependencies."""
|
|
71
|
+
|
|
72
|
+
sources = {}
|
|
73
|
+
try:
|
|
74
|
+
for name in ("codex_native_generation.py", "base.py"):
|
|
75
|
+
with Path(__file__).with_name(name).open("rb") as source:
|
|
76
|
+
content = source.read(_MAX_SOURCE_BYTES + 1)
|
|
77
|
+
if len(content) > _MAX_SOURCE_BYTES:
|
|
78
|
+
raise _protocol_error("Codex native generation parser source exceeds its identity limit")
|
|
79
|
+
sources[name] = hashlib.sha256(content).hexdigest()
|
|
80
|
+
except OSError:
|
|
81
|
+
raise _protocol_error("Codex native generation parser source identity is unavailable") from None
|
|
82
|
+
identity = json.dumps(
|
|
83
|
+
{"contract": PARSER_CONTRACT, "source_sha256": sources},
|
|
84
|
+
sort_keys=True,
|
|
85
|
+
separators=(",", ":"),
|
|
86
|
+
ensure_ascii=False,
|
|
87
|
+
)
|
|
88
|
+
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _object(
|
|
92
|
+
value: object, required: set[str] | frozenset[str], optional: frozenset[str] = frozenset()
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
if not isinstance(value, dict) or not required <= value.keys() or value.keys() - (required | optional):
|
|
95
|
+
raise _protocol_error()
|
|
96
|
+
return value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _text(value: object, *, nonempty: bool = False) -> str:
|
|
100
|
+
if not isinstance(value, str) or (nonempty and not value.strip()):
|
|
101
|
+
raise _protocol_error()
|
|
102
|
+
try:
|
|
103
|
+
value.encode("utf-8")
|
|
104
|
+
except UnicodeError:
|
|
105
|
+
raise _protocol_error() from None
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _json_value(value: object) -> None:
|
|
110
|
+
"""Validate wire JSON without interpreting tool-owned nested objects."""
|
|
111
|
+
|
|
112
|
+
pending = [value]
|
|
113
|
+
while pending:
|
|
114
|
+
part = pending.pop()
|
|
115
|
+
if isinstance(part, dict):
|
|
116
|
+
for key in part:
|
|
117
|
+
_text(key)
|
|
118
|
+
pending.extend(part.values())
|
|
119
|
+
elif isinstance(part, list):
|
|
120
|
+
pending.extend(part)
|
|
121
|
+
elif isinstance(part, str):
|
|
122
|
+
_text(part)
|
|
123
|
+
elif isinstance(part, float) and not math.isfinite(part):
|
|
124
|
+
raise _protocol_error()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _error(value: object) -> dict[str, Any]:
|
|
128
|
+
error = _object(value, {"message"})
|
|
129
|
+
_text(error["message"])
|
|
130
|
+
return error
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _status(value: object, *, command: bool = False) -> None:
|
|
134
|
+
if not isinstance(value, str) or value not in (_STATUSES | {"declined"} if command else _STATUSES):
|
|
135
|
+
raise _protocol_error()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _item(value: object) -> dict[str, Any]:
|
|
139
|
+
if not isinstance(value, dict):
|
|
140
|
+
raise _protocol_error()
|
|
141
|
+
_text(value.get("id"), nonempty=True)
|
|
142
|
+
item_type = _text(value.get("type"))
|
|
143
|
+
common = {"id", "type"}
|
|
144
|
+
if item_type in {"agent_message", "reasoning"}:
|
|
145
|
+
_object(value, common | {"text"})
|
|
146
|
+
_text(value["text"])
|
|
147
|
+
elif item_type == "error":
|
|
148
|
+
_object(value, common | {"message"})
|
|
149
|
+
_text(value["message"])
|
|
150
|
+
elif item_type == "command_execution":
|
|
151
|
+
_object(value, common | {"command", "aggregated_output", "exit_code", "status"})
|
|
152
|
+
_text(value["command"])
|
|
153
|
+
_text(value["aggregated_output"])
|
|
154
|
+
exit_code = value["exit_code"]
|
|
155
|
+
if exit_code is not None and (type(exit_code) is not int or not -(2**31) <= exit_code < 2**31):
|
|
156
|
+
raise _protocol_error()
|
|
157
|
+
_status(value["status"], command=True)
|
|
158
|
+
elif item_type == "file_change":
|
|
159
|
+
_object(value, common | {"changes", "status"})
|
|
160
|
+
_status(value["status"])
|
|
161
|
+
if not isinstance(value["changes"], list):
|
|
162
|
+
raise _protocol_error()
|
|
163
|
+
for change in value["changes"]:
|
|
164
|
+
_object(change, {"path", "kind"})
|
|
165
|
+
_text(change["path"])
|
|
166
|
+
if not isinstance(change["kind"], str) or change["kind"] not in {"add", "delete", "update"}:
|
|
167
|
+
raise _protocol_error()
|
|
168
|
+
elif item_type == "mcp_tool_call":
|
|
169
|
+
_object(value, common | {"server", "tool", "arguments", "result", "error", "status"})
|
|
170
|
+
_text(value["server"])
|
|
171
|
+
_text(value["tool"])
|
|
172
|
+
_status(value["status"])
|
|
173
|
+
if value["error"] is not None:
|
|
174
|
+
_error(value["error"])
|
|
175
|
+
if value["result"] is not None:
|
|
176
|
+
result = _object(value["result"], {"content", "structured_content"}, frozenset({"_meta"}))
|
|
177
|
+
if not isinstance(result["content"], list):
|
|
178
|
+
raise _protocol_error()
|
|
179
|
+
elif item_type == "collab_tool_call":
|
|
180
|
+
_object(
|
|
181
|
+
value, common | {"tool", "sender_thread_id", "receiver_thread_ids", "prompt", "agents_states", "status"}
|
|
182
|
+
)
|
|
183
|
+
if not isinstance(value["tool"], str) or value["tool"] not in {
|
|
184
|
+
"spawn_agent",
|
|
185
|
+
"send_input",
|
|
186
|
+
"wait",
|
|
187
|
+
"close_agent",
|
|
188
|
+
}:
|
|
189
|
+
raise _protocol_error()
|
|
190
|
+
_text(value["sender_thread_id"], nonempty=True)
|
|
191
|
+
if value["prompt"] is not None:
|
|
192
|
+
_text(value["prompt"])
|
|
193
|
+
if not isinstance(value["receiver_thread_ids"], list) or not isinstance(value["agents_states"], dict):
|
|
194
|
+
raise _protocol_error()
|
|
195
|
+
for receiver in value["receiver_thread_ids"]:
|
|
196
|
+
_text(receiver, nonempty=True)
|
|
197
|
+
for agent_id, state in value["agents_states"].items():
|
|
198
|
+
_text(agent_id, nonempty=True)
|
|
199
|
+
_object(state, {"status", "message"})
|
|
200
|
+
if not isinstance(state["status"], str) or state["status"] not in {
|
|
201
|
+
"pending_init",
|
|
202
|
+
"running",
|
|
203
|
+
"interrupted",
|
|
204
|
+
"completed",
|
|
205
|
+
"errored",
|
|
206
|
+
"shutdown",
|
|
207
|
+
"not_found",
|
|
208
|
+
}:
|
|
209
|
+
raise _protocol_error()
|
|
210
|
+
if state["message"] is not None:
|
|
211
|
+
_text(state["message"])
|
|
212
|
+
_status(value["status"])
|
|
213
|
+
elif item_type == "web_search":
|
|
214
|
+
_object(value, common | {"query", "action"}, frozenset({"results"}))
|
|
215
|
+
_text(value["query"])
|
|
216
|
+
# The imported WebSearchAction is tool-owned. The pinned mapper exposes
|
|
217
|
+
# its container, while results explicitly contain arbitrary JsonValue.
|
|
218
|
+
if not isinstance(value["action"], dict):
|
|
219
|
+
raise _protocol_error()
|
|
220
|
+
if "results" in value and not isinstance(value["results"], list):
|
|
221
|
+
raise _protocol_error()
|
|
222
|
+
elif item_type == "todo_list":
|
|
223
|
+
_object(value, common | {"items"})
|
|
224
|
+
if not isinstance(value["items"], list):
|
|
225
|
+
raise _protocol_error()
|
|
226
|
+
for step in value["items"]:
|
|
227
|
+
_object(step, {"text", "completed"})
|
|
228
|
+
_text(step["text"])
|
|
229
|
+
if type(step["completed"]) is not bool:
|
|
230
|
+
raise _protocol_error()
|
|
231
|
+
else:
|
|
232
|
+
raise _protocol_error()
|
|
233
|
+
return value
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _permission_denial(item: dict[str, Any]) -> bool:
|
|
237
|
+
"""Recognize only pinned refusal signatures, never arbitrary tool prose."""
|
|
238
|
+
|
|
239
|
+
if item["type"] == "command_execution" and item["status"] == "declined" and item["exit_code"] == -1:
|
|
240
|
+
output = item["aggregated_output"]
|
|
241
|
+
if output in _COMMAND_APPROVAL_DENIALS:
|
|
242
|
+
return True
|
|
243
|
+
marker = _EXEC_POLICY_DENIAL.fullmatch(output)
|
|
244
|
+
return (
|
|
245
|
+
marker is not None
|
|
246
|
+
and marker["command"] == item["command"]
|
|
247
|
+
and bool(marker["command"].strip())
|
|
248
|
+
and bool(marker["prefix"].strip())
|
|
249
|
+
)
|
|
250
|
+
return (
|
|
251
|
+
item["type"] == "mcp_tool_call"
|
|
252
|
+
and item["status"] == "failed"
|
|
253
|
+
and item["result"] is None
|
|
254
|
+
and item["error"] is not None
|
|
255
|
+
and item["error"]["message"] in _MCP_APPROVAL_DENIALS
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class CodexNativeGenerationParser(BaseParser):
|
|
260
|
+
"""Validate one native turn; leave final-message publication to its owner."""
|
|
261
|
+
|
|
262
|
+
name = "codex_native_generation_jsonl"
|
|
263
|
+
|
|
264
|
+
def parse(self, stdout: str, stderr: str = "") -> ParsedCLIResponse:
|
|
265
|
+
if not isinstance(stdout, str) or not isinstance(stderr, str):
|
|
266
|
+
raise _protocol_error()
|
|
267
|
+
_text(stdout)
|
|
268
|
+
_text(stderr)
|
|
269
|
+
lines = [line for line in stdout.split("\n") if line.strip(" \t\r")]
|
|
270
|
+
if not lines or len(lines) > MAX_EVENTS:
|
|
271
|
+
raise cli_failure(
|
|
272
|
+
stderr, message="Codex native generation output is empty or too large", code="protocol_error"
|
|
273
|
+
)
|
|
274
|
+
thread_started = False
|
|
275
|
+
turn_started = False
|
|
276
|
+
terminal = False
|
|
277
|
+
fatal: dict[str, Any] | None = None
|
|
278
|
+
failed: dict[str, Any] | None = None
|
|
279
|
+
item_types: dict[str, str] = {}
|
|
280
|
+
active_items: set[str] = set()
|
|
281
|
+
completed_items: set[str] = set()
|
|
282
|
+
metadata: dict[str, Any] = {"actual_model": None, "tool_error_count": 0, "permission_denial_count": 0}
|
|
283
|
+
|
|
284
|
+
for line in lines:
|
|
285
|
+
try:
|
|
286
|
+
event = load_json(line, allow_codex_web_search_ids=True)
|
|
287
|
+
except ParserError:
|
|
288
|
+
raise _protocol_error() from None
|
|
289
|
+
_json_value(event)
|
|
290
|
+
if not isinstance(event, dict) or not isinstance(event.get("type"), str) or terminal:
|
|
291
|
+
raise _protocol_error()
|
|
292
|
+
event_type = event["type"]
|
|
293
|
+
# Native terminal handling reconciles outstanding tool/todo items
|
|
294
|
+
# before emitting turn.failed. A prior fatal error still prevents
|
|
295
|
+
# success, but must not discard its safe authentication/rate code.
|
|
296
|
+
if fatal is not None and event_type not in {"item.completed", "turn.failed"}:
|
|
297
|
+
raise _protocol_error()
|
|
298
|
+
|
|
299
|
+
if event_type == "error":
|
|
300
|
+
_object(event, {"type", "message"})
|
|
301
|
+
_text(event["message"])
|
|
302
|
+
fatal = event
|
|
303
|
+
elif event_type == "turn.failed":
|
|
304
|
+
_object(event, {"type", "error"})
|
|
305
|
+
_error(event["error"])
|
|
306
|
+
failed = event
|
|
307
|
+
terminal = True
|
|
308
|
+
elif event_type == "thread.started":
|
|
309
|
+
_object(event, {"type", "thread_id"})
|
|
310
|
+
session = _text(event["thread_id"])
|
|
311
|
+
if thread_started or turn_started or not _SESSION.fullmatch(session):
|
|
312
|
+
raise _protocol_error()
|
|
313
|
+
thread_started = True
|
|
314
|
+
elif event_type == "turn.started":
|
|
315
|
+
_object(event, {"type"})
|
|
316
|
+
if not thread_started or turn_started:
|
|
317
|
+
raise _protocol_error()
|
|
318
|
+
turn_started = True
|
|
319
|
+
elif event_type in _ITEM_EVENTS:
|
|
320
|
+
_object(event, {"type", "item"})
|
|
321
|
+
item = _item(event["item"])
|
|
322
|
+
item_id, item_type = item["id"], item["type"]
|
|
323
|
+
if not thread_started or (
|
|
324
|
+
not turn_started and not (item_type == "error" and event_type == "item.completed")
|
|
325
|
+
):
|
|
326
|
+
raise _protocol_error()
|
|
327
|
+
if item_id in completed_items or (item_id in item_types and item_types[item_id] != item_type):
|
|
328
|
+
raise _protocol_error()
|
|
329
|
+
if event_type == "item.started" and item_id in item_types:
|
|
330
|
+
raise _protocol_error()
|
|
331
|
+
item_types[item_id] = item_type
|
|
332
|
+
if event_type == "item.completed":
|
|
333
|
+
if item_type in _TOOL_ITEMS and item["status"] == "in_progress":
|
|
334
|
+
raise _protocol_error()
|
|
335
|
+
active_items.discard(item_id)
|
|
336
|
+
completed_items.add(item_id)
|
|
337
|
+
tool_failed = item_type == "error" or (
|
|
338
|
+
item_type in _TOOL_ITEMS
|
|
339
|
+
and (
|
|
340
|
+
item["status"] in {"failed", "declined"}
|
|
341
|
+
or (item_type == "command_execution" and item["exit_code"] not in (None, 0))
|
|
342
|
+
or (item_type == "mcp_tool_call" and item["error"] is not None)
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
metadata["tool_error_count"] += int(tool_failed)
|
|
346
|
+
metadata["permission_denial_count"] += int(_permission_denial(item))
|
|
347
|
+
else:
|
|
348
|
+
active_items.add(item_id)
|
|
349
|
+
elif event_type == "turn.completed":
|
|
350
|
+
_object(event, {"type", "usage"})
|
|
351
|
+
if not turn_started or active_items:
|
|
352
|
+
raise _protocol_error()
|
|
353
|
+
usage = _object(event["usage"], _USAGE_REQUIRED, _USAGE_OPTIONAL)
|
|
354
|
+
if any(type(count) is not int or not 0 <= count <= MAX_METADATA_NUMBER for count in usage.values()):
|
|
355
|
+
raise _protocol_error()
|
|
356
|
+
metadata["usage"] = usage.copy()
|
|
357
|
+
terminal = True
|
|
358
|
+
else:
|
|
359
|
+
raise _protocol_error()
|
|
360
|
+
|
|
361
|
+
if metadata["permission_denial_count"]:
|
|
362
|
+
raise ParserError("Codex native generation requires permission", code="permission_needed")
|
|
363
|
+
if failed is not None or fatal is not None or not terminal:
|
|
364
|
+
raise cli_failure(
|
|
365
|
+
failed, fatal, stderr, message="Codex native generation did not complete", code="protocol_error"
|
|
366
|
+
)
|
|
367
|
+
return ParsedCLIResponse(content="", metadata=metadata)
|
clink/parsers/gemini.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Parser for Gemini CLI JSON output; execution availability is adapter policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import (
|
|
8
|
+
BaseParser,
|
|
9
|
+
ParsedCLIResponse,
|
|
10
|
+
ParserError,
|
|
11
|
+
bounded_counts,
|
|
12
|
+
bounded_number,
|
|
13
|
+
cli_failure,
|
|
14
|
+
load_json,
|
|
15
|
+
model_identifier,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_TOKEN_FIELDS = ("prompt", "candidates", "total", "cached", "thoughts", "tool")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GeminiJSONParser(BaseParser):
|
|
22
|
+
"""Require response text and reject explicit Gemini errors."""
|
|
23
|
+
|
|
24
|
+
name = "gemini_json"
|
|
25
|
+
|
|
26
|
+
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
|
27
|
+
if not stdout.strip():
|
|
28
|
+
raise cli_failure(stderr, message="Gemini CLI returned empty stdout")
|
|
29
|
+
|
|
30
|
+
payload = load_json(stdout)
|
|
31
|
+
if not isinstance(payload, dict):
|
|
32
|
+
raise ParserError("Gemini CLI JSON must be an object")
|
|
33
|
+
if "type" in payload and not isinstance(payload["type"], str):
|
|
34
|
+
raise ParserError("Gemini CLI response type must be textual")
|
|
35
|
+
if "status" in payload and not isinstance(payload["status"], str):
|
|
36
|
+
raise ParserError("Gemini CLI response status must be textual")
|
|
37
|
+
if (
|
|
38
|
+
payload.get("type") in ("error", "failed")
|
|
39
|
+
or payload.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
40
|
+
or payload.get("error") is not None
|
|
41
|
+
or payload.get("errors")
|
|
42
|
+
or payload.get("is_error", False) is not False
|
|
43
|
+
):
|
|
44
|
+
raise cli_failure(payload, stderr, message="Gemini CLI reported a failure")
|
|
45
|
+
if payload.get("status", "success") not in ("completed", "success"):
|
|
46
|
+
raise ParserError("Gemini CLI response status did not indicate success")
|
|
47
|
+
|
|
48
|
+
metadata = self._metadata(payload, stderr)
|
|
49
|
+
response = payload.get("response")
|
|
50
|
+
if not isinstance(response, str):
|
|
51
|
+
raise ParserError("Gemini CLI response must be textual")
|
|
52
|
+
if not response.strip():
|
|
53
|
+
raise cli_failure(stderr, message="Gemini CLI response was empty", code="empty_response")
|
|
54
|
+
|
|
55
|
+
return ParsedCLIResponse(content=response.strip(), metadata=metadata)
|
|
56
|
+
|
|
57
|
+
def _metadata(self, payload: dict[str, Any], stderr: str) -> dict[str, Any]:
|
|
58
|
+
stats = payload.get("stats", {})
|
|
59
|
+
if not isinstance(stats, dict):
|
|
60
|
+
raise ParserError("Gemini CLI stats must be an object")
|
|
61
|
+
pending: list[object] = [stats]
|
|
62
|
+
while pending:
|
|
63
|
+
record = pending.pop()
|
|
64
|
+
if isinstance(record, list):
|
|
65
|
+
pending.extend(record)
|
|
66
|
+
elif isinstance(record, dict):
|
|
67
|
+
if "status" in record and not isinstance(record["status"], str):
|
|
68
|
+
raise ParserError("Gemini CLI stats status must be textual")
|
|
69
|
+
if (
|
|
70
|
+
record.get("type") in ("error", "failed")
|
|
71
|
+
or record.get("status") in ("error", "failed", "cancelled", "aborted", "interrupted")
|
|
72
|
+
or record.get("error") is not None
|
|
73
|
+
or record.get("errors")
|
|
74
|
+
or record.get("is_error", False) is not False
|
|
75
|
+
):
|
|
76
|
+
raise cli_failure(record, stderr, message="Gemini CLI stats reported a failure")
|
|
77
|
+
for field in ("totalErrors", "totalFail", "fail"):
|
|
78
|
+
if field not in record:
|
|
79
|
+
continue
|
|
80
|
+
errors = record[field]
|
|
81
|
+
if not isinstance(errors, int) or isinstance(errors, bool) or errors < 0:
|
|
82
|
+
raise ParserError("Gemini CLI failure counts must be nonnegative integers")
|
|
83
|
+
if errors:
|
|
84
|
+
raise cli_failure(stderr, message="Gemini CLI stats reported a failure")
|
|
85
|
+
pending.extend(record.values())
|
|
86
|
+
models = stats.get("models", {})
|
|
87
|
+
if not isinstance(models, dict):
|
|
88
|
+
raise ParserError("Gemini CLI model stats must be objects")
|
|
89
|
+
|
|
90
|
+
metadata: dict[str, Any] = {}
|
|
91
|
+
for model_stats in models.values():
|
|
92
|
+
if not isinstance(model_stats, dict):
|
|
93
|
+
raise ParserError("Gemini CLI model stats must be objects")
|
|
94
|
+
api_stats = model_stats.get("api", {})
|
|
95
|
+
if not isinstance(api_stats, dict):
|
|
96
|
+
raise ParserError("Gemini CLI API stats must be an object")
|
|
97
|
+
|
|
98
|
+
if len(models) == 1:
|
|
99
|
+
model_name, model_stats = next(iter(models.items()))
|
|
100
|
+
actual_model = model_identifier(model_name)
|
|
101
|
+
if actual_model:
|
|
102
|
+
metadata["actual_model"] = actual_model
|
|
103
|
+
tokens = bounded_counts(model_stats.get("tokens"), _TOKEN_FIELDS)
|
|
104
|
+
if tokens:
|
|
105
|
+
metadata["usage"] = tokens
|
|
106
|
+
latency = bounded_number(model_stats.get("api", {}).get("totalLatencyMs"))
|
|
107
|
+
if latency is not None:
|
|
108
|
+
metadata["latency_ms"] = latency
|
|
109
|
+
|
|
110
|
+
return metadata
|
clink/parsers/grok.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Parse Grok Build's single-document JSON result without admitting execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .base import BaseParser, ParsedCLIResponse, ParserError, bounded_counts, cli_failure, load_json, model_identifier
|
|
8
|
+
|
|
9
|
+
_USAGE_FIELDS = (
|
|
10
|
+
"input_tokens",
|
|
11
|
+
"output_tokens",
|
|
12
|
+
"cache_read_input_tokens",
|
|
13
|
+
"cache_creation_input_tokens",
|
|
14
|
+
"reasoning_tokens",
|
|
15
|
+
"total_tokens",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GrokJSONParser(BaseParser):
|
|
20
|
+
"""Require a complete end_turn response, rejecting partial or error results."""
|
|
21
|
+
|
|
22
|
+
name = "grok_json"
|
|
23
|
+
|
|
24
|
+
def parse(self, stdout: str, stderr: str) -> ParsedCLIResponse:
|
|
25
|
+
if not stdout.strip():
|
|
26
|
+
raise cli_failure(stderr, message="Grok CLI returned empty stdout")
|
|
27
|
+
|
|
28
|
+
payload = load_json(stdout)
|
|
29
|
+
if not isinstance(payload, dict):
|
|
30
|
+
raise ParserError("Grok CLI JSON must be an object")
|
|
31
|
+
if (
|
|
32
|
+
payload.get("type") in ("error", "failed")
|
|
33
|
+
or payload.get("error") is not None
|
|
34
|
+
or ("errors" in payload and payload["errors"] is not None and payload["errors"] != [])
|
|
35
|
+
or payload.get("is_error", False) is not False
|
|
36
|
+
):
|
|
37
|
+
raise cli_failure(payload, stderr, message="Grok CLI reported a failure")
|
|
38
|
+
if "type" in payload or "event" in payload or "status" in payload:
|
|
39
|
+
raise ParserError("Grok CLI returned an unsupported JSON dialect")
|
|
40
|
+
# Installed 1.0.70 documents end_turn, max_tokens, max_turn_requests,
|
|
41
|
+
# refusal and cancelled. Only end_turn establishes a complete answer.
|
|
42
|
+
if payload.get("stopReason") != "end_turn":
|
|
43
|
+
raise ParserError("Grok CLI did not include a complete terminal stop reason")
|
|
44
|
+
|
|
45
|
+
text = payload.get("text")
|
|
46
|
+
if not isinstance(text, str):
|
|
47
|
+
raise ParserError("Grok CLI response must be textual")
|
|
48
|
+
if not text.strip():
|
|
49
|
+
raise cli_failure(stderr, message="Grok CLI response was empty", code="empty_response")
|
|
50
|
+
|
|
51
|
+
metadata: dict[str, Any] = bounded_counts(payload, ("num_turns",))
|
|
52
|
+
usage = bounded_counts(payload.get("usage"), _USAGE_FIELDS)
|
|
53
|
+
if usage:
|
|
54
|
+
metadata["usage"] = usage
|
|
55
|
+
|
|
56
|
+
model_usage = payload.get("modelUsage")
|
|
57
|
+
if isinstance(model_usage, dict) and len(model_usage) == 1:
|
|
58
|
+
model_name, model_stats = next(iter(model_usage.items()))
|
|
59
|
+
actual_model = model_identifier(model_name)
|
|
60
|
+
if actual_model and actual_model != "unknown" and isinstance(model_stats, dict):
|
|
61
|
+
metadata["actual_model"] = actual_model
|
|
62
|
+
|
|
63
|
+
return ParsedCLIResponse(content=text.strip(), metadata=metadata)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Parse Grok native JSON and documented headless refusal diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from clink.native_contracts import ParsedNativeResponse
|
|
9
|
+
from clink.parsers.base import ParserError, bounded_counts, cli_failure, load_json, model_identifier
|
|
10
|
+
|
|
11
|
+
RESULT_CHARACTERS = 20000
|
|
12
|
+
MAX_EVENTS = 16384
|
|
13
|
+
_SESSION = re.compile(r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\Z")
|
|
14
|
+
_PERMISSION_REFUSAL = re.compile(
|
|
15
|
+
r"permission required: [^\r\n]+ was not run, as a headless run cannot ask for approval\Z"
|
|
16
|
+
)
|
|
17
|
+
_STOP_REASONS = {"end_turn", "max_tokens", "max_turn_requests", "refusal", "cancelled"}
|
|
18
|
+
_USAGE_FIELDS = (
|
|
19
|
+
"input_tokens",
|
|
20
|
+
"output_tokens",
|
|
21
|
+
"cache_read_input_tokens",
|
|
22
|
+
"cache_creation_input_tokens",
|
|
23
|
+
"reasoning_tokens",
|
|
24
|
+
"total_tokens",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_session_id(value: str | None) -> str | None:
|
|
29
|
+
"""Accept only the UUID identity consumed by Grok's native session flags."""
|
|
30
|
+
|
|
31
|
+
if value is not None and (not isinstance(value, str) or not _SESSION.fullmatch(value)):
|
|
32
|
+
raise ParserError("Grok native session identity is invalid")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GrokNativeParser:
|
|
37
|
+
"""Keep native execution outcomes separate from advisory answer validity."""
|
|
38
|
+
|
|
39
|
+
def parse(self, stdout: str, stderr: str = "") -> ParsedNativeResponse:
|
|
40
|
+
denials = min(MAX_EVENTS, sum(bool(_PERMISSION_REFUSAL.fullmatch(line)) for line in stderr.splitlines()))
|
|
41
|
+
if not stdout.strip():
|
|
42
|
+
if denials:
|
|
43
|
+
return ParsedNativeResponse(
|
|
44
|
+
"permission_needed", metadata={"permission_denial_count": denials, "truncated": False}
|
|
45
|
+
)
|
|
46
|
+
raise cli_failure(stderr, message="Grok native output was empty")
|
|
47
|
+
payload = load_json(stdout)
|
|
48
|
+
if not isinstance(payload, dict):
|
|
49
|
+
raise ParserError("Grok native JSON must be an object")
|
|
50
|
+
if any(name in payload for name in ("event", "status")) or ("type" in payload and payload["type"] != "error"):
|
|
51
|
+
raise ParserError("Grok native output used an unsupported JSON dialect")
|
|
52
|
+
if "is_error" in payload and not isinstance(payload["is_error"], bool):
|
|
53
|
+
raise ParserError("Grok native error flag must be boolean")
|
|
54
|
+
if "errors" in payload and payload["errors"] is not None and not isinstance(payload["errors"], list):
|
|
55
|
+
raise ParserError("Grok native errors must be an array")
|
|
56
|
+
session_id = validate_session_id(payload.get("sessionId"))
|
|
57
|
+
error = (
|
|
58
|
+
payload.get("type") == "error"
|
|
59
|
+
or payload.get("error") is not None
|
|
60
|
+
or bool(payload.get("errors"))
|
|
61
|
+
or payload.get("is_error") is True
|
|
62
|
+
)
|
|
63
|
+
reason = payload.get("stopReason")
|
|
64
|
+
if reason is not None and (not isinstance(reason, str) or reason not in _STOP_REASONS):
|
|
65
|
+
raise ParserError("Grok native terminal stop reason is invalid")
|
|
66
|
+
if reason is None and not error:
|
|
67
|
+
raise ParserError("Grok native output omitted its terminal stop reason")
|
|
68
|
+
if not error and session_id is None:
|
|
69
|
+
raise ParserError("Grok native terminal result omitted its session identity")
|
|
70
|
+
content = payload.get("text", "") if error else payload.get("text")
|
|
71
|
+
if not isinstance(content, str):
|
|
72
|
+
raise ParserError("Grok native result must be textual")
|
|
73
|
+
state = "permission_needed" if denials else "completed" if reason == "end_turn" and not error else "failed"
|
|
74
|
+
metadata: dict[str, Any] = {
|
|
75
|
+
"permission_denial_count": denials,
|
|
76
|
+
"truncated": len(content) > RESULT_CHARACTERS,
|
|
77
|
+
}
|
|
78
|
+
if reason is not None:
|
|
79
|
+
metadata["terminal_stop_reason"] = reason
|
|
80
|
+
metadata.update(bounded_counts(payload, ("num_turns",)))
|
|
81
|
+
if usage := bounded_counts(payload.get("usage"), _USAGE_FIELDS):
|
|
82
|
+
metadata["usage"] = usage
|
|
83
|
+
model_usage = payload.get("modelUsage")
|
|
84
|
+
if isinstance(model_usage, dict) and len(model_usage) == 1:
|
|
85
|
+
model, stats = next(iter(model_usage.items()))
|
|
86
|
+
actual_model = model_identifier(model)
|
|
87
|
+
if actual_model is not None and actual_model != "unknown" and isinstance(stats, dict):
|
|
88
|
+
metadata["actual_model"] = actual_model
|
|
89
|
+
if state == "failed":
|
|
90
|
+
metadata["error_code"] = cli_failure(payload, stderr, code="native_error").code
|
|
91
|
+
return ParsedNativeResponse(state, content[:RESULT_CHARACTERS], session_id, metadata)
|