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.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. utils/token_utils.py +54 -0
clink/tasks.py ADDED
@@ -0,0 +1,695 @@
1
+ """Server-owned native jobs with atomic deduplication and bounded volatile retention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import math
10
+ import re
11
+ import time
12
+ import uuid
13
+ from collections.abc import Callable
14
+ from dataclasses import asdict, dataclass, field, replace
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from clink.contracts import BackendError
19
+ from clink.native_contracts import FrozenNativeContext, NativeRunOutcome, NativeRuntime
20
+
21
+ logger = logging.getLogger("clink.tasks")
22
+ MAX_TASKS = 64
23
+ MAX_ATTEMPTS = 16
24
+ MAX_RETAINED_BYTES = 8 * 1024 * 1024
25
+ RESULT_CHARACTERS = 20000
26
+ RESULT_METADATA_BYTES = 8192
27
+ # A JSON control character needs six bytes even when UTF-8 itself needs only one.
28
+ _COMPLETION_RESERVATION_BYTES = RESULT_CHARACTERS * 6 + RESULT_METADATA_BYTES + 4096
29
+ TERMINAL_TTL_SECONDS = 3 * 60 * 60
30
+ DEADLINE_SECONDS = 1800
31
+ PROMPT_BYTES = 1024 * 1024
32
+ _ACTIVE = frozenset({"queued", "running"})
33
+ _TERMINAL = frozenset({"completed", "permission_needed", "failed", "cancelled", "uncertain"})
34
+ _NAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,63}\Z")
35
+ _KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}\Z")
36
+ _ERROR_CODES = frozenset(
37
+ {
38
+ "authentication_required",
39
+ "cancelled",
40
+ "cli_missing",
41
+ "invalid_config",
42
+ "input_limit",
43
+ "missing_environment",
44
+ "policy_unverified",
45
+ "recursion_blocked",
46
+ "native_unavailable",
47
+ "native_error",
48
+ "native_session_mismatch",
49
+ "nonzero_exit",
50
+ "output_limit",
51
+ "parse_error",
52
+ "path_not_allowed",
53
+ "permission_needed",
54
+ "queue_full",
55
+ "queue_timeout",
56
+ "rate_limited",
57
+ "session_unavailable",
58
+ "timeout",
59
+ "unsupported_capability",
60
+ "unsupported_platform",
61
+ "verification_required",
62
+ }
63
+ )
64
+ # Admission exceptions may contain private diagnostics; publish only code-indexed text.
65
+ _PREPARE_DIAGNOSTICS = {
66
+ "path_not_allowed": (
67
+ "Native project_dir and context paths must be absolute, accessible, and within the configured allowed_roots.",
68
+ "Supply project_dir and context paths within an allowed_roots entry; inspect listmodels for the selected "
69
+ "client's allowed_roots. To authorize another workspace, update allowed_roots in the operator-owned CLI "
70
+ "manifest using `clink allow /absolute/workspace --cli NAME --yes` (replace NAME with the selected CLI; "
71
+ "include `--project /bound/project` for a bound server), then restart the server.",
72
+ )
73
+ }
74
+ # Native output is untrusted. Public metadata must not become a second raw-stream channel.
75
+ _METADATA_KEYS = frozenset(
76
+ {
77
+ "actual_model",
78
+ "duration_seconds",
79
+ "duration_ms",
80
+ "duration_api_ms",
81
+ "num_turns",
82
+ "exit_status",
83
+ "stdout_bytes",
84
+ "stderr_bytes",
85
+ "queue_seconds",
86
+ "verification_status",
87
+ "configuration_sources",
88
+ "missing_environment_names",
89
+ "environment_names",
90
+ "permission_denial_count",
91
+ "tool_error_count",
92
+ "truncated",
93
+ "read_scope",
94
+ "repository_instructions",
95
+ "cleanup_scope",
96
+ }
97
+ )
98
+
99
+
100
+ def _encoded_size(value: Any) -> int:
101
+ return len(json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8"))
102
+
103
+
104
+ @dataclass
105
+ class _Attempt:
106
+ attempt_id: str
107
+ state: str
108
+ admitted_at: float
109
+ started_at: float | None = None
110
+ terminal_at: float | None = None
111
+ possible_partial_effects: bool = False
112
+ cancel_requested: bool = False
113
+ error_code: str | None = None
114
+
115
+
116
+ @dataclass
117
+ class _TaskRecord:
118
+ task_handle: str
119
+ context: FrozenNativeContext
120
+ attempts: list[_Attempt] = field(default_factory=list)
121
+ session_id: str | None = None
122
+ last_terminal_at: float | None = None
123
+ latest_result: dict[str, Any] | None = None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class _KeyEntry:
128
+ fingerprint: str
129
+ task_handle: str
130
+ attempt_id: str
131
+
132
+
133
+ class NativeTaskManager:
134
+ """Own each native input until cleanup; no prompt bodies or PAL history enter records."""
135
+
136
+ def __init__(
137
+ self,
138
+ runtime: NativeRuntime,
139
+ *,
140
+ clock: Callable[[], float] = time.monotonic,
141
+ max_tasks: int = MAX_TASKS,
142
+ max_attempts: int = MAX_ATTEMPTS,
143
+ max_retained_bytes: int = MAX_RETAINED_BYTES,
144
+ terminal_ttl: float = TERMINAL_TTL_SECONDS,
145
+ ) -> None:
146
+ self.runtime = runtime
147
+ self._clock = clock
148
+ self.max_tasks = min(max_tasks, MAX_TASKS)
149
+ self.max_attempts = min(max_attempts, MAX_ATTEMPTS)
150
+ self.max_retained_bytes = min(max_retained_bytes, MAX_RETAINED_BYTES)
151
+ self.terminal_ttl = min(terminal_ttl, TERMINAL_TTL_SECONDS)
152
+ if min(self.max_tasks, self.max_attempts, self.max_retained_bytes, self.terminal_ttl) <= 0:
153
+ raise ValueError("Native task limits must be positive.")
154
+ self._records: dict[str, _TaskRecord] = {}
155
+ self._keys: dict[str, _KeyEntry] = {}
156
+ self._jobs: dict[str, asyncio.Task[None]] = {}
157
+ self._leases: dict[str, Path] = {}
158
+ self._session_owners: dict[tuple[str, str], str] = {}
159
+ self._lock = asyncio.Lock()
160
+ self._closed = False
161
+
162
+ @staticmethod
163
+ def _validate(args: dict[str, Any], action: str) -> dict[str, Any]:
164
+ if not isinstance(args, dict):
165
+ raise BackendError("invalid_config", "Native task arguments must be an object.")
166
+ fields = {"action", "task_handle"}
167
+ if action in {"start", "followup"}:
168
+ fields |= {
169
+ "cli_name",
170
+ "role",
171
+ "model",
172
+ "project_dir",
173
+ "prompt",
174
+ "files",
175
+ "absolute_file_paths",
176
+ "images",
177
+ "request_key",
178
+ "timeout_seconds",
179
+ }
180
+ if set(args) - fields or (args.get("action") is not None and args["action"] != action):
181
+ raise BackendError("invalid_config", "Unsupported arguments for this native task action.")
182
+ value = dict(args)
183
+ value.pop("action", None)
184
+ if action != "start":
185
+ handle = value.get("task_handle")
186
+ if not isinstance(handle, str) or not re.fullmatch(r"nt_[a-f0-9]{32}", handle):
187
+ raise BackendError("invalid_config", "A valid native task handle is required.")
188
+ elif "task_handle" in value:
189
+ raise BackendError("invalid_config", "Start does not accept an existing task handle.")
190
+ if action not in {"start", "followup"}:
191
+ return value
192
+ if "absolute_file_paths" in value:
193
+ if "files" in value:
194
+ raise BackendError("invalid_config", "Supply native context paths through one field only.")
195
+ value["files"] = value.pop("absolute_file_paths")
196
+ timeout = value.get("timeout_seconds", DEADLINE_SECONDS)
197
+ try:
198
+ valid_timeout = (
199
+ not isinstance(timeout, bool)
200
+ and isinstance(timeout, (int, float))
201
+ and math.isfinite(timeout)
202
+ and 1 <= timeout <= DEADLINE_SECONDS
203
+ )
204
+ except OverflowError:
205
+ valid_timeout = False
206
+ if not valid_timeout:
207
+ raise BackendError("invalid_config", "Native timeout_seconds must be a finite number from 1 to 1800.")
208
+ value["timeout_seconds"] = float(timeout)
209
+ key = value.get("request_key")
210
+ if not isinstance(key, str) or not _KEY.fullmatch(key):
211
+ raise BackendError("invalid_config", "A request_key of 1 to 128 ASCII identifier characters is required.")
212
+ prompt = value.get("prompt")
213
+ try:
214
+ valid_prompt = (
215
+ isinstance(prompt, str) and bool(prompt.strip()) and len(prompt.encode("utf-8")) <= PROMPT_BYTES
216
+ )
217
+ except UnicodeEncodeError:
218
+ valid_prompt = False
219
+ if not valid_prompt:
220
+ raise BackendError("invalid_config", "A nonempty native task prompt within the 1 MiB limit is required.")
221
+ for name in ("cli_name", "role"):
222
+ selection = value.get(name)
223
+ if selection is not None and (not isinstance(selection, str) or not _NAME.fullmatch(selection)):
224
+ raise BackendError("invalid_config", "Native CLI and role names must be bounded identifiers.")
225
+ if action == "start" and not value.get("cli_name"):
226
+ raise BackendError("invalid_config", "Start requires explicit cli_name selection.")
227
+ model = value.get("model")
228
+ if model is not None and (
229
+ not isinstance(model, str)
230
+ or not model
231
+ or model != model.strip()
232
+ or len(model) > 256
233
+ or model.startswith("-")
234
+ or any(ord(char) < 32 or ord(char) == 127 for char in model)
235
+ ):
236
+ raise BackendError("invalid_config", "The native model selection is invalid.")
237
+ project = value.get("project_dir")
238
+ if action == "start" or project is not None:
239
+ if not isinstance(project, str) or len(project) > 4096 or not Path(project).is_absolute():
240
+ raise BackendError("path_not_allowed", "An absolute native project directory is required.")
241
+ # Retry identity uses the submitted spelling. A completed job may have moved the
242
+ # directory or retargeted a symlink; redelivery must still find that original input.
243
+ # The runtime canonicalizes and approves only genuinely new submissions below.
244
+ for name in ("files", "images"):
245
+ paths = value.get(name, ())
246
+ if (
247
+ not isinstance(paths, (list, tuple))
248
+ or len(paths) > 256
249
+ or any(not isinstance(path, str) or not path or len(path) > 4096 for path in paths)
250
+ ):
251
+ raise BackendError("invalid_config", "Native context paths must be bounded lists of path strings.")
252
+ value[name] = tuple(paths)
253
+ return value
254
+
255
+ def _expire(self) -> None:
256
+ now = self._clock()
257
+ expired = {
258
+ handle
259
+ for handle, record in self._records.items()
260
+ if record.attempts[-1].state not in _ACTIVE
261
+ and record.last_terminal_at is not None
262
+ and now - record.last_terminal_at >= self.terminal_ttl
263
+ }
264
+ for handle in expired:
265
+ del self._records[handle]
266
+ if expired:
267
+ self._keys = {key: entry for key, entry in self._keys.items() if entry.task_handle not in expired}
268
+ self._session_owners = {
269
+ identity: handle for identity, handle in self._session_owners.items() if handle not in expired
270
+ }
271
+
272
+ @staticmethod
273
+ def _fingerprint(args: dict[str, Any], action: str) -> str:
274
+ inputs = {key: value for key, value in args.items() if key != "request_key"}
275
+ return hashlib.sha256(
276
+ json.dumps({"action": action, **inputs}, sort_keys=True, separators=(",", ":")).encode("utf-8")
277
+ ).hexdigest()
278
+
279
+ @staticmethod
280
+ def _record_size(record: _TaskRecord) -> int:
281
+ return _encoded_size(asdict(record))
282
+
283
+ @property
284
+ def retained_bytes(self) -> int:
285
+ """Count retained context, index, attempt metadata, and worst-case active results."""
286
+ return (
287
+ sum(self._record_size(record) for record in self._records.values())
288
+ + _encoded_size({key: asdict(value) for key, value in self._keys.items()})
289
+ + _encoded_size(list(self._session_owners.items()))
290
+ + _encoded_size({handle: str(project) for handle, project in self._leases.items()})
291
+ + len(self._leases) * _COMPLETION_RESERVATION_BYTES
292
+ )
293
+
294
+ def _check_capacity(self, candidate: _TaskRecord, key: str, entry: _KeyEntry) -> None:
295
+ previous = self._records.get(candidate.task_handle)
296
+ extra = self._record_size(candidate) - (self._record_size(previous) if previous is not None else 0)
297
+ # Include index punctuation and reserve the largest permitted completion before launching.
298
+ extra += (
299
+ _encoded_size({key: asdict(entry)})
300
+ + _encoded_size({candidate.task_handle: str(candidate.context.target.working_directory)})
301
+ + _COMPLETION_RESERVATION_BYTES
302
+ )
303
+ if self.retained_bytes + extra > self.max_retained_bytes:
304
+ raise BackendError("task_capacity", "Native task retention capacity is full; no task was submitted.")
305
+
306
+ def _deduplicate(self, key: str, fingerprint: str) -> dict[str, Any] | None:
307
+ entry = self._keys.get(key)
308
+ if entry is None:
309
+ return None
310
+ if entry.fingerprint != fingerprint:
311
+ raise BackendError("request_key_conflict", "This request key was already used for different native input.")
312
+ record = self._records[entry.task_handle]
313
+ attempt = next(item for item in record.attempts if item.attempt_id == entry.attempt_id)
314
+ return self._snapshot(record, attempt, deduplicated=True)
315
+
316
+ def _reserve_project(self, handle: str, project: Path) -> None:
317
+ for active in self._leases.values():
318
+ if project == active or project in active.parents or active in project.parents:
319
+ raise BackendError("project_busy", "An overlapping native project already has active work.")
320
+ self._leases[handle] = project
321
+
322
+ def _admission_time(self, admitted_at: float | None) -> float:
323
+ value = self._clock() if admitted_at is None else admitted_at
324
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
325
+ raise BackendError("invalid_config", "Native admission time must be a finite monotonic timestamp.")
326
+ return value
327
+
328
+ def _prepare(self, *args: Any, **kwargs: Any) -> FrozenNativeContext:
329
+ try:
330
+ return self.runtime.prepare(*args, **kwargs)
331
+ except BackendError as exc:
332
+ code = exc.code if exc.code in _ERROR_CODES else "native_unavailable"
333
+ message, remedy = _PREPARE_DIAGNOSTICS.get(code, ("Native task admission could not be established.", ""))
334
+ selected_cli = args[0] if args else kwargs.get("cli_name")
335
+ if code == "path_not_allowed" and isinstance(selected_cli, str) and _NAME.fullmatch(selected_cli):
336
+ remedy = remedy.replace("--cli NAME", f"--cli {selected_cli}").replace(
337
+ "(replace NAME with the selected CLI; include", "(include"
338
+ )
339
+ missing = exc.metadata.get("missing_environment_names")
340
+ metadata = {}
341
+ if isinstance(missing, list):
342
+ metadata["missing_environment_names"] = [
343
+ name
344
+ for name in missing[:64]
345
+ if isinstance(name, str) and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", name)
346
+ ]
347
+ raise BackendError(code, message, remedy, metadata=metadata) from None
348
+ except Exception:
349
+ raise BackendError("native_unavailable", "Native task admission could not be established.") from None
350
+
351
+ async def start(
352
+ self, args: dict[str, Any], admitted_at: float | None = None, request_id: str | None = None
353
+ ) -> dict[str, Any]:
354
+ """Reserve the retry identity and project before scheduling server-owned execution."""
355
+ values = self._validate(args, "start")
356
+ fingerprint = self._fingerprint(values, "start")
357
+ submitted = self._admission_time(admitted_at)
358
+ async with self._lock:
359
+ self._expire()
360
+ duplicate = self._deduplicate(values["request_key"], fingerprint)
361
+ if duplicate is not None:
362
+ return duplicate
363
+ if self._closed:
364
+ raise BackendError("cancelled", "The native task manager is shutting down.")
365
+ if len(self._records) >= self.max_tasks:
366
+ raise BackendError("task_capacity", "Native task capacity is full; no task was submitted.")
367
+ context = self._prepare(
368
+ values["cli_name"],
369
+ values.get("role"),
370
+ values.get("model"),
371
+ values["project_dir"],
372
+ values["files"],
373
+ values["images"],
374
+ absolute_deadline=submitted + values["timeout_seconds"],
375
+ )
376
+ handle = "nt_" + uuid.uuid4().hex
377
+ attempt = _Attempt("na_" + uuid.uuid4().hex, "queued", submitted)
378
+ # Context attachments belong only to this input, never to future native follow-ups.
379
+ record = _TaskRecord(handle, replace(context, files=(), images=(), deadline=None), [attempt])
380
+ entry = _KeyEntry(fingerprint, handle, attempt.attempt_id)
381
+ self._check_capacity(record, values["request_key"], entry)
382
+ self._reserve_project(handle, context.target.working_directory)
383
+ self._records[handle] = record
384
+ self._keys[values["request_key"]] = entry
385
+ self._jobs[handle] = asyncio.create_task(
386
+ self._execute(handle, attempt, context, values["prompt"], None), name=f"native-{attempt.attempt_id}"
387
+ )
388
+ return self._snapshot(record, attempt)
389
+
390
+ async def followup(
391
+ self, args: dict[str, Any], admitted_at: float | None = None, request_id: str | None = None
392
+ ) -> dict[str, Any]:
393
+ """Submit only new input to the recorded native session and frozen selection."""
394
+ values = self._validate(args, "followup")
395
+ fingerprint = self._fingerprint(values, "followup")
396
+ submitted = self._admission_time(admitted_at)
397
+ async with self._lock:
398
+ self._expire()
399
+ duplicate = self._deduplicate(values["request_key"], fingerprint)
400
+ if duplicate is not None:
401
+ return duplicate
402
+ if self._closed:
403
+ raise BackendError("cancelled", "The native task manager is shutting down.")
404
+ record = self._records.get(values["task_handle"])
405
+ if record is None:
406
+ return self._unavailable(values["task_handle"])
407
+ if record.attempts[-1].state in _ACTIVE:
408
+ raise BackendError("task_busy", "This native task already has active input.")
409
+ if record.session_id is None:
410
+ raise BackendError(
411
+ "session_unavailable", "The recorded native session is unavailable; no task was submitted."
412
+ )
413
+ if len(record.attempts) >= self.max_attempts:
414
+ raise BackendError(
415
+ "task_capacity", "This native task reached its attempt limit; no input was submitted."
416
+ )
417
+ target = record.context.target
418
+ context = self._prepare(
419
+ values.get("cli_name") or target.cli_name,
420
+ values.get("role") or target.role.name,
421
+ values.get("model", target.requested_model),
422
+ values.get("project_dir") or str(target.working_directory),
423
+ values["files"],
424
+ values["images"],
425
+ absolute_deadline=submitted + values["timeout_seconds"],
426
+ )
427
+ identity = lambda item: ( # noqa: E731 -- keep the complete binding visible at the comparison.
428
+ item.target.cli_name,
429
+ item.target.role.name,
430
+ item.target.effective_model,
431
+ item.target.working_directory,
432
+ item.profile_id,
433
+ )
434
+ if identity(context) != identity(record.context):
435
+ raise BackendError("session_conflict", "Follow-up input conflicts with the recorded native selection.")
436
+ context = replace(record.context, files=context.files, images=context.images, deadline=context.deadline)
437
+ attempt = _Attempt("na_" + uuid.uuid4().hex, "queued", submitted)
438
+ candidate = replace(record, attempts=[*record.attempts, attempt], latest_result=None)
439
+ entry = _KeyEntry(fingerprint, record.task_handle, attempt.attempt_id)
440
+ self._check_capacity(candidate, values["request_key"], entry)
441
+ self._reserve_project(record.task_handle, target.working_directory)
442
+ self._records[record.task_handle] = candidate
443
+ self._keys[values["request_key"]] = entry
444
+ self._jobs[record.task_handle] = asyncio.create_task(
445
+ self._execute(record.task_handle, attempt, context, values["prompt"], record.session_id),
446
+ name=f"native-{attempt.attempt_id}",
447
+ )
448
+ return self._snapshot(candidate, attempt)
449
+
450
+ @staticmethod
451
+ def _public_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
452
+ safe: dict[str, Any] = {}
453
+ if not isinstance(metadata, dict):
454
+ return safe
455
+ for key in sorted(_METADATA_KEYS):
456
+ if key not in metadata:
457
+ continue
458
+ value = metadata[key]
459
+ # Scalars and short lists/maps only; arbitrary parser structures cannot bypass the result cap.
460
+ if isinstance(value, str):
461
+ value = value[:512]
462
+ elif value is None or isinstance(value, bool):
463
+ pass
464
+ elif isinstance(value, (int, float)):
465
+ try:
466
+ finite = math.isfinite(value) and abs(value) <= 10**15
467
+ except (OverflowError, TypeError):
468
+ finite = False
469
+ if not finite:
470
+ continue
471
+ elif isinstance(value, list) and all(isinstance(item, str) for item in value):
472
+ value = [item[:128] for item in value[:32]]
473
+ else:
474
+ continue
475
+ try:
476
+ fits = _encoded_size({**safe, key: value}) <= RESULT_METADATA_BYTES
477
+ except (UnicodeEncodeError, ValueError):
478
+ fits = False
479
+ if fits:
480
+ safe[key] = value
481
+ return safe
482
+
483
+ async def _execute(
484
+ self,
485
+ handle: str,
486
+ attempt: _Attempt,
487
+ context: FrozenNativeContext,
488
+ prompt: str,
489
+ session_id: str | None,
490
+ ) -> None:
491
+ outcome: NativeRunOutcome | None = None
492
+ state = "uncertain"
493
+ error_code = None
494
+
495
+ def started() -> None:
496
+ attempt.started_at = self._clock()
497
+ attempt.possible_partial_effects = True
498
+ attempt.state = "running"
499
+
500
+ try:
501
+ outcome = await self.runtime.run(
502
+ context, prompt, session_id, attempt_id=attempt.attempt_id, started=started
503
+ )
504
+ if not isinstance(outcome, NativeRunOutcome):
505
+ outcome = None
506
+ raise BackendError("parse_error", "The native runtime returned no recognized terminal outcome.")
507
+ state = outcome.state if outcome.state in _TERMINAL else "uncertain"
508
+ native_error = outcome.metadata.get("error_code") if isinstance(outcome.metadata, dict) else None
509
+ error_code = native_error if isinstance(native_error, str) and native_error in _ERROR_CODES else None
510
+ attempt.possible_partial_effects = attempt.possible_partial_effects or outcome.partial_effects
511
+ except asyncio.CancelledError:
512
+ state = "cancelled"
513
+ except BackendError as exc:
514
+ error_code = exc.code if exc.code in _ERROR_CODES else "native_failed"
515
+ if error_code == "permission_needed":
516
+ state = "permission_needed"
517
+ else:
518
+ state = "uncertain" if attempt.started_at is not None else "failed"
519
+ except Exception:
520
+ error_code = "native_failed"
521
+ state = "uncertain" if attempt.started_at is not None else "failed"
522
+ finally:
523
+ # The runtime contract guarantees process cleanup is already complete at this point.
524
+ prompt = ""
525
+ async with self._lock:
526
+ record = self._records[handle]
527
+ if outcome is not None:
528
+ native_id = outcome.session_id
529
+ if native_id is not None and (
530
+ not isinstance(native_id, str)
531
+ or not native_id
532
+ or len(native_id) > 512
533
+ or any(ord(char) < 32 or ord(char) == 127 for char in native_id)
534
+ ):
535
+ native_id = None
536
+ state, error_code = "uncertain", "session_unavailable"
537
+ if native_id is not None:
538
+ owner = self._session_owners.get((record.context.target.cli_name, native_id))
539
+ if owner is not None and owner != handle:
540
+ state, error_code = "uncertain", "session_conflict"
541
+ elif record.session_id is not None and record.session_id != native_id:
542
+ state, error_code = "uncertain", "session_conflict"
543
+ else:
544
+ record.session_id = native_id
545
+ self._session_owners[(record.context.target.cli_name, native_id)] = handle
546
+ raw_content = outcome.content if isinstance(outcome.content, str) else ""
547
+ # Invalid Unicode is treated as untrusted output rather than failing finalization.
548
+ content = raw_content[:RESULT_CHARACTERS].encode("utf-8", errors="replace").decode("utf-8")
549
+ record.latest_result = {
550
+ "content": content,
551
+ "truncated": len(raw_content) > RESULT_CHARACTERS
552
+ or (isinstance(outcome.metadata, dict) and outcome.metadata.get("truncated") is True),
553
+ "agent_reported_claims": "unverified",
554
+ "metadata": self._public_metadata(outcome.metadata),
555
+ }
556
+ attempt.state = state
557
+ attempt.error_code = error_code
558
+ attempt.terminal_at = self._clock()
559
+ record.last_terminal_at = attempt.terminal_at
560
+ self._leases.pop(handle, None)
561
+ self._jobs.pop(handle, None)
562
+ logger.info(
563
+ "native_attempt task_handle=%s attempt_id=%s state=%s possible_partial_effects=%s",
564
+ handle,
565
+ attempt.attempt_id,
566
+ state,
567
+ attempt.possible_partial_effects,
568
+ )
569
+
570
+ @staticmethod
571
+ def _unavailable(handle: str) -> dict[str, Any]:
572
+ return {
573
+ "task_handle": handle,
574
+ "state": "unavailable",
575
+ "execution_outcome": "unknown",
576
+ "result_unavailable": True,
577
+ "execution_does_not_verify_task_fulfillment": True,
578
+ "possible_partial_effects": True,
579
+ }
580
+
581
+ @staticmethod
582
+ def _snapshot(record: _TaskRecord, attempt: _Attempt, *, deduplicated: bool = False) -> dict[str, Any]:
583
+ latest = record.attempts[-1] is attempt
584
+ result = record.latest_result if latest else None
585
+ response: dict[str, Any] = {
586
+ "task_handle": record.task_handle,
587
+ "attempt_id": attempt.attempt_id,
588
+ "state": attempt.state,
589
+ "execution_outcome": "unknown" if attempt.state == "uncertain" else attempt.state,
590
+ "admitted_at": attempt.admitted_at,
591
+ "started_at": attempt.started_at,
592
+ "terminal_at": attempt.terminal_at,
593
+ "possible_partial_effects": attempt.possible_partial_effects,
594
+ "cancellation_requested": attempt.cancel_requested,
595
+ "execution_does_not_verify_task_fulfillment": True,
596
+ "result_unavailable": result is None,
597
+ "deduplicated": deduplicated,
598
+ }
599
+ if result is not None:
600
+ # Return detached data so callers cannot mutate retained result metadata.
601
+ response["result"] = json.loads(json.dumps(result))
602
+ if attempt.error_code is not None:
603
+ response["error"] = {"code": attempt.error_code, "message": "Native execution did not establish success."}
604
+ if latest:
605
+ response["followup_available"] = record.session_id is not None and attempt.state not in _ACTIVE
606
+ return response
607
+
608
+ async def status(self, args: dict[str, Any] | str) -> dict[str, Any]:
609
+ values = self._validate({"task_handle": args} if isinstance(args, str) else args, "status")
610
+ async with self._lock:
611
+ self._expire()
612
+ record = self._records.get(values["task_handle"])
613
+ if record is None:
614
+ return self._unavailable(values["task_handle"])
615
+ return self._snapshot(record, record.attempts[-1])
616
+
617
+ async def _cancel_job(self, handle: str, attempt: _Attempt, job: asyncio.Task[None]) -> None:
618
+ await asyncio.gather(job, return_exceptions=True)
619
+ async with self._lock:
620
+ # A task cancelled before its coroutine first executes has no finally block.
621
+ if attempt.state in _ACTIVE:
622
+ attempt.state = "cancelled"
623
+ attempt.terminal_at = self._clock()
624
+ self._records[handle].last_terminal_at = attempt.terminal_at
625
+ self._leases.pop(handle, None)
626
+ self._jobs.pop(handle, None)
627
+
628
+ @staticmethod
629
+ async def _finish_cleanup(cleanup: asyncio.Task[Any]) -> None:
630
+ interrupted = False
631
+ while not cleanup.done():
632
+ try:
633
+ await asyncio.shield(cleanup)
634
+ except asyncio.CancelledError:
635
+ interrupted = True
636
+ cleanup.result()
637
+ if interrupted:
638
+ raise asyncio.CancelledError
639
+
640
+ async def cancel(self, args: dict[str, Any] | str) -> dict[str, Any]:
641
+ values = self._validate({"task_handle": args} if isinstance(args, str) else args, "cancel")
642
+ async with self._lock:
643
+ self._expire()
644
+ record = self._records.get(values["task_handle"])
645
+ if record is None:
646
+ return self._unavailable(values["task_handle"])
647
+ attempt = record.attempts[-1]
648
+ job = self._jobs.get(record.task_handle)
649
+ if job is None or attempt.state not in _ACTIVE:
650
+ return self._snapshot(record, attempt)
651
+ if not attempt.cancel_requested:
652
+ attempt.cancel_requested = True
653
+ job.cancel()
654
+ cleanup = asyncio.create_task(self._cancel_job(record.task_handle, attempt, job))
655
+ await self._finish_cleanup(cleanup)
656
+ async with self._lock:
657
+ # Another explicit follow-up may already own the task. Return the cancelled input only.
658
+ return self._snapshot(self._records.get(record.task_handle, record), attempt)
659
+
660
+ async def shutdown(self) -> None:
661
+ """Close admission and await every process cleanup before releasing native ownership."""
662
+ async with self._lock:
663
+ self._closed = True
664
+ pending = []
665
+ for handle, job in tuple(self._jobs.items()):
666
+ attempt = self._records[handle].attempts[-1]
667
+ if not attempt.cancel_requested:
668
+ attempt.cancel_requested = True
669
+ job.cancel()
670
+ pending.append(asyncio.create_task(self._cancel_job(handle, attempt, job)))
671
+ if pending:
672
+
673
+ async def finish() -> None:
674
+ await asyncio.gather(*pending)
675
+
676
+ await self._finish_cleanup(asyncio.create_task(finish()))
677
+
678
+
679
+ _TASK_MANAGER: NativeTaskManager | None = None
680
+
681
+
682
+ def get_task_manager() -> NativeTaskManager:
683
+ """Lazily share the server-owned manager without creating another process pool."""
684
+ global _TASK_MANAGER
685
+ if _TASK_MANAGER is None:
686
+ from clink.native import get_native_runtime
687
+
688
+ _TASK_MANAGER = NativeTaskManager(get_native_runtime())
689
+ return _TASK_MANAGER
690
+
691
+
692
+ async def shutdown_task_manager() -> None:
693
+ """Drain only an existing native manager; shutdown must never instantiate a backend."""
694
+ if _TASK_MANAGER is not None:
695
+ await _TASK_MANAGER.shutdown()