delegate-agent-cli 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- delegate_agent/__init__.py +5 -0
- delegate_agent/archived_logs.py +44 -0
- delegate_agent/argv_builders.py +423 -0
- delegate_agent/argv_utils.py +36 -0
- delegate_agent/capability_commands.py +99 -0
- delegate_agent/cli.py +987 -0
- delegate_agent/cli_parser.py +1627 -0
- delegate_agent/command_errors.py +29 -0
- delegate_agent/command_help.py +1264 -0
- delegate_agent/config.py +1117 -0
- delegate_agent/config_commands.py +143 -0
- delegate_agent/constants.py +50 -0
- delegate_agent/describe_payload.py +1018 -0
- delegate_agent/errors.py +32 -0
- delegate_agent/git_utils.py +180 -0
- delegate_agent/harness_events.py +719 -0
- delegate_agent/inspection_commands.py +104 -0
- delegate_agent/isolation.py +316 -0
- delegate_agent/json_types.py +18 -0
- delegate_agent/log_output.py +78 -0
- delegate_agent/private_io.py +109 -0
- delegate_agent/profile_commands.py +42 -0
- delegate_agent/profile_guard.py +116 -0
- delegate_agent/profiles.py +389 -0
- delegate_agent/prompt_instructions.py +39 -0
- delegate_agent/prompt_transport.py +12 -0
- delegate_agent/reasoning.py +916 -0
- delegate_agent/redaction.py +193 -0
- delegate_agent/rendering.py +573 -0
- delegate_agent/request_build.py +1681 -0
- delegate_agent/request_models.py +191 -0
- delegate_agent/retention.py +321 -0
- delegate_agent/run_metadata.py +78 -0
- delegate_agent/run_output_commands.py +645 -0
- delegate_agent/run_registry.py +747 -0
- delegate_agent/run_status.py +300 -0
- delegate_agent/runner.py +1830 -0
- delegate_agent/safe_workspace.py +821 -0
- delegate_agent/snapshot_view.py +229 -0
- delegate_agent/wait_cancel_commands.py +559 -0
- delegate_agent/worktree_commands.py +218 -0
- delegate_agent/worktree_execution.py +654 -0
- delegate_agent/worktree_gc.py +529 -0
- delegate_agent/worktree_mgmt.py +782 -0
- delegate_agent/worktree_records.py +232 -0
- delegate_agent/worktree_remove.py +547 -0
- delegate_agent/worktree_summary.py +242 -0
- delegate_agent/wsl.py +65 -0
- delegate_agent_cli-0.11.0.dist-info/METADATA +325 -0
- delegate_agent_cli-0.11.0.dist-info/RECORD +54 -0
- delegate_agent_cli-0.11.0.dist-info/WHEEL +5 -0
- delegate_agent_cli-0.11.0.dist-info/entry_points.txt +2 -0
- delegate_agent_cli-0.11.0.dist-info/licenses/LICENSE +21 -0
- delegate_agent_cli-0.11.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1681 @@
|
|
|
1
|
+
"""Request construction.
|
|
2
|
+
|
|
3
|
+
Turns a parsed command plus resolved config into a launch-ready ``Request``:
|
|
4
|
+
workspace/prompt resolution, per-engine request-parts assembly (dispatched via
|
|
5
|
+
``ENGINE_REQUEST_PARTS_BUILDERS``), reasoning-effort resolution, and the
|
|
6
|
+
JSON/stdin request entry points. The per-engine parts stay explicit because the
|
|
7
|
+
engines differ materially; the argv strings themselves are built in
|
|
8
|
+
``argv_builders``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import select
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess # nosec B404 - Delegate inspects git workspaces with shell=False.
|
|
19
|
+
import tempfile
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import replace
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TextIO
|
|
24
|
+
|
|
25
|
+
from delegate_agent import config as delegate_config
|
|
26
|
+
from delegate_agent import profiles, reasoning, safe_workspace, wsl
|
|
27
|
+
from delegate_agent import runner as delegate_runner
|
|
28
|
+
from delegate_agent.argv_builders import (
|
|
29
|
+
SAFE_REVIEW_PREFIX_BY_ENGINE,
|
|
30
|
+
_claude_harness_bypass_enabled,
|
|
31
|
+
_grok_harness_bypass_enabled,
|
|
32
|
+
build_claude_argv,
|
|
33
|
+
build_codex_argv,
|
|
34
|
+
build_cursor_argv,
|
|
35
|
+
build_droid_argv,
|
|
36
|
+
build_grok_argv,
|
|
37
|
+
build_kimi_argv,
|
|
38
|
+
prefix_droid_safe_prompt,
|
|
39
|
+
redacted_prompt_argv,
|
|
40
|
+
)
|
|
41
|
+
from delegate_agent.constants import (
|
|
42
|
+
ENGINES_PROSE,
|
|
43
|
+
KNOWN_ENGINES,
|
|
44
|
+
MODE_CALL,
|
|
45
|
+
MODE_SAFE,
|
|
46
|
+
MODE_WORK,
|
|
47
|
+
MODELESS_NONCURSOR_ENGINES,
|
|
48
|
+
SAFE_REVIEW_PREFIX_INJECTED_HERE_ENGINES,
|
|
49
|
+
validate_mode,
|
|
50
|
+
)
|
|
51
|
+
from delegate_agent.errors import DelegateError
|
|
52
|
+
from delegate_agent.git_utils import GIT_QUICK_TIMEOUT_SECONDS, capture_git_metadata
|
|
53
|
+
from delegate_agent.git_utils import run_git as _run_git
|
|
54
|
+
from delegate_agent.isolation import IsolationContext, build_isolation_context
|
|
55
|
+
from delegate_agent.json_types import JsonObject, JsonValue
|
|
56
|
+
from delegate_agent.prompt_transport import (
|
|
57
|
+
DROID_PROMPT_FILE_ARG_PLACEHOLDER,
|
|
58
|
+
DROID_PROMPT_FILE_DISPLAY,
|
|
59
|
+
KIMI_PROMPT_REDACTION,
|
|
60
|
+
PROMPT_FILE_ARG_PLACEHOLDER,
|
|
61
|
+
PROMPT_FILE_DISPLAY,
|
|
62
|
+
PROMPT_TRANSPORT_ARGV,
|
|
63
|
+
PROMPT_TRANSPORT_FILE,
|
|
64
|
+
PROMPT_TRANSPORT_STDIN,
|
|
65
|
+
)
|
|
66
|
+
from delegate_agent.request_models import (
|
|
67
|
+
EngineBuildInput,
|
|
68
|
+
EngineRequestParts,
|
|
69
|
+
ParsedCommand,
|
|
70
|
+
Request,
|
|
71
|
+
ResolvedWorkspace,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
RUN_INPUT_KEYS = {
|
|
75
|
+
"engine",
|
|
76
|
+
"mode",
|
|
77
|
+
"model",
|
|
78
|
+
"cwd",
|
|
79
|
+
"prompt",
|
|
80
|
+
"isolation",
|
|
81
|
+
"reasoningEffort",
|
|
82
|
+
"outputSchema",
|
|
83
|
+
"progress",
|
|
84
|
+
"forbidCommit",
|
|
85
|
+
"readOnly",
|
|
86
|
+
"includeDirty",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
OUTPUT_SCHEMA_COMPLETION_REPORT_WARNING = (
|
|
90
|
+
"--output-schema enforces a JSON-only final message; completion-report instruction "
|
|
91
|
+
"suppressed for this run."
|
|
92
|
+
)
|
|
93
|
+
CALL_TEMP_CWD_PLACEHOLDER = "<delegate-call-temp-cwd>"
|
|
94
|
+
CODEX_HARNESS_DEFAULT_REASONING_EFFORTS = ("low", "medium", "high", "xhigh")
|
|
95
|
+
|
|
96
|
+
# Read-only call is the stateless "judge/completion" contract: text in, text out,
|
|
97
|
+
# no tree. These harnesses default to a coding-agent framing ("inspect the
|
|
98
|
+
# workspace") that derails a judge prompt on an empty cwd, so neutralize that
|
|
99
|
+
# framing. The no-mutation clause is load-bearing for cursor/droid/kimi, whose
|
|
100
|
+
# read-only call has no CLI sandbox — the prompt is the only write boundary there
|
|
101
|
+
# (codex/claude/grok also get a real read-only sandbox flag). Work-level call is
|
|
102
|
+
# left raw — it may legitimately act in the cwd.
|
|
103
|
+
CALL_READONLY_PREAMBLE = (
|
|
104
|
+
"You are being called to respond to the following prompt directly. There is "
|
|
105
|
+
"no repository, working tree, or codebase to inspect, open, or review, and "
|
|
106
|
+
"you must not create, edit, delete, or execute anything — produce your answer "
|
|
107
|
+
"from the prompt text alone.\n\n"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _call_effective_prompt(prompt: str, *, read_only: bool) -> str:
|
|
112
|
+
if read_only and not prompt.startswith(CALL_READONLY_PREAMBLE):
|
|
113
|
+
return f"{CALL_READONLY_PREAMBLE}{prompt}"
|
|
114
|
+
return prompt
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _policy_mode(build: EngineBuildInput) -> str:
|
|
118
|
+
"""Resolve which policy tier a call inherits: default call is work-level, a
|
|
119
|
+
read-only call is safe-level. Bypass flags stay bound to real work mode via
|
|
120
|
+
the argv builders' own ``mode == MODE_WORK`` gates, so borrowing the work
|
|
121
|
+
policy tier here only carries webSearch/networkAccess, never a bypass."""
|
|
122
|
+
if build.mode == MODE_CALL:
|
|
123
|
+
return MODE_SAFE if build.call_read_only else MODE_WORK
|
|
124
|
+
return build.mode
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _reject_windows_path(value: str, field: str) -> None:
|
|
128
|
+
if wsl.should_reject_windows_path(value):
|
|
129
|
+
raise DelegateError("windows_path", wsl.windows_path_message(field, value))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def load_config(
|
|
133
|
+
path: Path | None = None,
|
|
134
|
+
*,
|
|
135
|
+
workspace: Path | None = None,
|
|
136
|
+
cli_overrides: JsonObject | None = None,
|
|
137
|
+
) -> tuple[JsonObject, str]:
|
|
138
|
+
try:
|
|
139
|
+
return delegate_config.load_config(path, workspace=workspace, cli_overrides=cli_overrides)
|
|
140
|
+
except delegate_config.ConfigError as exc:
|
|
141
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate_config(config: JsonObject) -> None:
|
|
145
|
+
try:
|
|
146
|
+
delegate_config.validate_config(config)
|
|
147
|
+
except delegate_config.ConfigError as exc:
|
|
148
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
ProgressIntent = str | None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def resolve_effective_progress(intent: ProgressIntent, config: JsonObject) -> bool:
|
|
155
|
+
if intent == "off":
|
|
156
|
+
return False
|
|
157
|
+
if intent == "on":
|
|
158
|
+
return True
|
|
159
|
+
progress_section = config.get("progress")
|
|
160
|
+
if isinstance(progress_section, dict):
|
|
161
|
+
enabled = progress_section.get("enabled", False)
|
|
162
|
+
if isinstance(enabled, bool):
|
|
163
|
+
return enabled
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def resolve_progress_timing(config: JsonObject) -> tuple[float, float]:
|
|
168
|
+
default_initial = delegate_config.default_progress_initial_delay_sec()
|
|
169
|
+
default_interval = delegate_config.default_progress_interval_sec()
|
|
170
|
+
progress_section = config.get("progress")
|
|
171
|
+
if not isinstance(progress_section, dict):
|
|
172
|
+
return (default_initial, default_interval)
|
|
173
|
+
initial = progress_section.get("initialDelaySec", default_initial)
|
|
174
|
+
interval = progress_section.get("intervalSec", default_interval)
|
|
175
|
+
return float(initial), float(interval)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def resolve_workspace(global_cwd: str | None, json_cwd: str | None = None) -> ResolvedWorkspace:
|
|
179
|
+
if global_cwd and json_cwd:
|
|
180
|
+
global_workspace = workspace_for(global_cwd)
|
|
181
|
+
json_workspace = workspace_for(json_cwd)
|
|
182
|
+
if Path(global_workspace.path).resolve() != Path(json_workspace.path).resolve():
|
|
183
|
+
raise DelegateError(
|
|
184
|
+
"ambiguous_cwd", "CLI --cwd and JSON cwd resolve to different workspaces."
|
|
185
|
+
)
|
|
186
|
+
return global_workspace
|
|
187
|
+
if json_cwd:
|
|
188
|
+
return workspace_for(json_cwd)
|
|
189
|
+
if global_cwd:
|
|
190
|
+
return workspace_for(global_cwd)
|
|
191
|
+
return workspace_for(os.getcwd())
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def workspace_for(path_text: str) -> ResolvedWorkspace:
|
|
195
|
+
_reject_windows_path(path_text, "cwd")
|
|
196
|
+
path = Path(path_text).expanduser().resolve()
|
|
197
|
+
if not path.exists() or not path.is_dir():
|
|
198
|
+
raise DelegateError("invalid_cwd", f"cwd does not exist or is not a directory: {path}")
|
|
199
|
+
git_root = git_root_for(path)
|
|
200
|
+
if git_root is not None:
|
|
201
|
+
return ResolvedWorkspace(git_root, "git")
|
|
202
|
+
return ResolvedWorkspace(str(path), "directory")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def git_root_for(path: Path) -> str | None:
|
|
206
|
+
message = wsl.windows_git_message(shutil.which("git"))
|
|
207
|
+
if message is not None:
|
|
208
|
+
raise DelegateError("windows_git_in_wsl", message)
|
|
209
|
+
try:
|
|
210
|
+
result = _run_git(
|
|
211
|
+
str(path),
|
|
212
|
+
["rev-parse", "--show-toplevel"],
|
|
213
|
+
timeout_seconds=GIT_QUICK_TIMEOUT_SECONDS,
|
|
214
|
+
)
|
|
215
|
+
except (FileNotFoundError, subprocess.SubprocessError):
|
|
216
|
+
return None
|
|
217
|
+
if result.returncode != 0:
|
|
218
|
+
return None
|
|
219
|
+
return str(Path(result.stdout.strip()).resolve())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def resolve_prompt(
|
|
223
|
+
prompt_parts: list[str] | None,
|
|
224
|
+
prompt_file: str | None,
|
|
225
|
+
stdin: TextIO,
|
|
226
|
+
) -> str:
|
|
227
|
+
direct = " ".join(prompt_parts or [])
|
|
228
|
+
has_direct = bool(direct)
|
|
229
|
+
has_prompt_file = prompt_file is not None
|
|
230
|
+
stdin_text = read_stdin_source(stdin, block=not (has_direct or has_prompt_file))
|
|
231
|
+
has_stdin = stdin_text is not None
|
|
232
|
+
if sum(1 for present in (has_direct, has_prompt_file, has_stdin) if present) > 1:
|
|
233
|
+
raise DelegateError(
|
|
234
|
+
"ambiguous_prompt_source",
|
|
235
|
+
"Use exactly one prompt source: direct args, --prompt-file, or stdin.",
|
|
236
|
+
)
|
|
237
|
+
if has_direct:
|
|
238
|
+
return validate_prompt(direct)
|
|
239
|
+
if has_prompt_file:
|
|
240
|
+
prompt_file_path = prompt_file
|
|
241
|
+
if prompt_file_path is None:
|
|
242
|
+
raise DelegateError("missing_prompt_file", "--prompt-file requires a path.")
|
|
243
|
+
_reject_windows_path(prompt_file_path, "--prompt-file")
|
|
244
|
+
path = Path(prompt_file_path).expanduser()
|
|
245
|
+
try:
|
|
246
|
+
return validate_prompt(path.read_text(encoding="utf-8"))
|
|
247
|
+
except FileNotFoundError:
|
|
248
|
+
raise DelegateError("prompt_file_not_found", f"Prompt file not found: {path}") from None
|
|
249
|
+
if has_stdin:
|
|
250
|
+
stdin_prompt = stdin_text
|
|
251
|
+
if stdin_prompt is None:
|
|
252
|
+
raise DelegateError("missing_prompt", "Missing stdin prompt.")
|
|
253
|
+
return validate_prompt(stdin_prompt)
|
|
254
|
+
raise DelegateError(
|
|
255
|
+
"missing_prompt", "Missing prompt; pass prompt text, --prompt-file, or stdin."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def read_stdin_source(stdin: TextIO, *, block: bool = False) -> str | None:
|
|
260
|
+
if stdin.isatty():
|
|
261
|
+
return None
|
|
262
|
+
if not block:
|
|
263
|
+
try:
|
|
264
|
+
ready, _, _ = select.select([stdin], [], [], 0)
|
|
265
|
+
if not ready:
|
|
266
|
+
return None
|
|
267
|
+
except (AttributeError, OSError, ValueError):
|
|
268
|
+
if not isinstance(stdin, io.StringIO):
|
|
269
|
+
return None
|
|
270
|
+
data = stdin.read()
|
|
271
|
+
return data if data else None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def validate_prompt(prompt: str) -> str:
|
|
275
|
+
cleaned = "".join(ch for ch in prompt if ord(ch) >= 0x20 or ch in ("\n", "\r", "\t"))
|
|
276
|
+
if not cleaned.strip():
|
|
277
|
+
raise DelegateError("empty_prompt", "Prompt is empty.")
|
|
278
|
+
return cleaned
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def resolve_output_schema(engine: str, output_schema: object) -> str | None:
|
|
282
|
+
if output_schema is None:
|
|
283
|
+
return None
|
|
284
|
+
if engine == "grok":
|
|
285
|
+
raise DelegateError(
|
|
286
|
+
"unsupported_output_schema",
|
|
287
|
+
"Grok --json-schema forces final json output, which breaks Delegate tracked "
|
|
288
|
+
"streaming snapshots; --output-schema is not supported for grok in v1.",
|
|
289
|
+
)
|
|
290
|
+
if engine != "codex":
|
|
291
|
+
raise DelegateError(
|
|
292
|
+
"unsupported_output_schema",
|
|
293
|
+
"--output-schema/outputSchema is only supported by the codex engine; "
|
|
294
|
+
f"{engine} has no native schema enforcement.",
|
|
295
|
+
)
|
|
296
|
+
if not isinstance(output_schema, str) or not output_schema:
|
|
297
|
+
raise DelegateError("invalid_output_schema", "outputSchema must be a non-empty string.")
|
|
298
|
+
_reject_windows_path(output_schema, "outputSchema")
|
|
299
|
+
path = Path(output_schema).expanduser()
|
|
300
|
+
if not path.is_absolute():
|
|
301
|
+
path = Path.cwd() / path
|
|
302
|
+
path = path.resolve()
|
|
303
|
+
if not path.exists():
|
|
304
|
+
raise DelegateError("output_schema_not_found", f"Output schema not found: {path}")
|
|
305
|
+
if not path.is_file():
|
|
306
|
+
raise DelegateError("invalid_output_schema", f"Output schema is not a file: {path}")
|
|
307
|
+
try:
|
|
308
|
+
with path.open("rb"):
|
|
309
|
+
pass
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
raise DelegateError(
|
|
312
|
+
"invalid_output_schema", f"Output schema is not readable: {path}"
|
|
313
|
+
) from exc
|
|
314
|
+
return str(path)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _completion_report_prompt_mode(
|
|
318
|
+
completion_report_mode: str,
|
|
319
|
+
output_schema: str | None,
|
|
320
|
+
) -> tuple[str, tuple[str, ...]]:
|
|
321
|
+
if (
|
|
322
|
+
output_schema is not None
|
|
323
|
+
and completion_report_mode == delegate_config.COMPLETION_REPORT_MODE_MARKDOWN
|
|
324
|
+
):
|
|
325
|
+
return (
|
|
326
|
+
delegate_config.COMPLETION_REPORT_MODE_NONE,
|
|
327
|
+
(OUTPUT_SCHEMA_COMPLETION_REPORT_WARNING,),
|
|
328
|
+
)
|
|
329
|
+
return completion_report_mode, ()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def resolve_completion_report_mode(parsed: ParsedCommand, config: JsonObject) -> str:
|
|
333
|
+
global_options = parsed.global_options
|
|
334
|
+
if global_options.pass_through:
|
|
335
|
+
return delegate_config.COMPLETION_REPORT_MODE_NONE
|
|
336
|
+
if global_options.completion_report is not None:
|
|
337
|
+
return global_options.completion_report
|
|
338
|
+
return delegate_config.completion_report_default_mode(config)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def effective_prompt(
|
|
342
|
+
prompt: str,
|
|
343
|
+
*,
|
|
344
|
+
engine: str = "",
|
|
345
|
+
mode: str = "",
|
|
346
|
+
completion_report_mode: str,
|
|
347
|
+
) -> str:
|
|
348
|
+
if mode == MODE_CALL:
|
|
349
|
+
return prompt
|
|
350
|
+
prompt = delegate_runner.prepend_skill_review_instructions(prompt)
|
|
351
|
+
safe_prefix = (
|
|
352
|
+
SAFE_REVIEW_PREFIX_BY_ENGINE.get(engine)
|
|
353
|
+
if engine in SAFE_REVIEW_PREFIX_INJECTED_HERE_ENGINES
|
|
354
|
+
else None
|
|
355
|
+
)
|
|
356
|
+
if mode == MODE_SAFE and safe_prefix is not None and safe_prefix not in prompt:
|
|
357
|
+
# prepend_skill_review_instructions guarantees SKILL_REVIEW_PREFIX at index 0,
|
|
358
|
+
# so provider-specific safe prefixes slot in cleanly between skill-review
|
|
359
|
+
# and the user prompt.
|
|
360
|
+
insert_at = len(delegate_runner.SKILL_REVIEW_PREFIX)
|
|
361
|
+
prompt = prompt[:insert_at] + safe_prefix + prompt[insert_at:]
|
|
362
|
+
if completion_report_mode == delegate_config.COMPLETION_REPORT_MODE_MARKDOWN:
|
|
363
|
+
return delegate_runner.append_completion_report_instructions(prompt)
|
|
364
|
+
return prompt
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _safe_dirty_tree_note(
|
|
368
|
+
resolved: ResolvedWorkspace,
|
|
369
|
+
mode: str,
|
|
370
|
+
isolation_context: IsolationContext | None,
|
|
371
|
+
) -> str | None:
|
|
372
|
+
if mode != MODE_SAFE or resolved.kind != "git":
|
|
373
|
+
return None
|
|
374
|
+
if isolation_context is None or isolation_context.effective_isolation != "worktree":
|
|
375
|
+
return None
|
|
376
|
+
git_root = isolation_context.source_git_root
|
|
377
|
+
if git_root is None or not safe_workspace.git_head_exists(git_root):
|
|
378
|
+
return None
|
|
379
|
+
# ponytail: one bounded git status probe per safe review; cache if this ever shows up hot.
|
|
380
|
+
changed = safe_workspace.changed_files_vs_head(git_root)
|
|
381
|
+
if not changed:
|
|
382
|
+
return None
|
|
383
|
+
shown = [f"`{path}`" for path in changed[:20]]
|
|
384
|
+
remaining = len(changed) - len(shown)
|
|
385
|
+
if remaining > 0:
|
|
386
|
+
shown.append(f"+{remaining} more")
|
|
387
|
+
return (
|
|
388
|
+
f"Note: {len(changed)} file(s) have uncommitted/untracked changes synced into "
|
|
389
|
+
f"this review copy: {', '.join(shown)}. Run `git diff HEAD` and check "
|
|
390
|
+
"untracked files in the workspace to see them."
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _append_safe_dirty_tree_note(
|
|
395
|
+
prompt: str,
|
|
396
|
+
resolved: ResolvedWorkspace,
|
|
397
|
+
mode: str,
|
|
398
|
+
isolation_context: IsolationContext | None,
|
|
399
|
+
) -> str:
|
|
400
|
+
note = _safe_dirty_tree_note(resolved, mode, isolation_context)
|
|
401
|
+
if note is None:
|
|
402
|
+
return prompt
|
|
403
|
+
return f"{prompt}\n\n{note}"
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _validate_forbid_commit(
|
|
407
|
+
*,
|
|
408
|
+
forbid_commit: bool,
|
|
409
|
+
mode: str,
|
|
410
|
+
isolation_context: IsolationContext | None,
|
|
411
|
+
) -> None:
|
|
412
|
+
if not forbid_commit:
|
|
413
|
+
return
|
|
414
|
+
if mode != MODE_WORK:
|
|
415
|
+
raise DelegateError(
|
|
416
|
+
"invalid_option_combination",
|
|
417
|
+
"--forbid-commit requires work mode with persistent worktree isolation.",
|
|
418
|
+
)
|
|
419
|
+
source_workspace = (
|
|
420
|
+
isolation_context.source_workspace
|
|
421
|
+
if isolation_context is not None
|
|
422
|
+
else "the selected workspace"
|
|
423
|
+
)
|
|
424
|
+
if isolation_context is not None and isolation_context.source_git_root is None:
|
|
425
|
+
raise DelegateError(
|
|
426
|
+
"invalid_option_combination",
|
|
427
|
+
"--forbid-commit needs worktree isolation, which requires a Git workspace; "
|
|
428
|
+
f"{source_workspace} is not a Git repo, so no-commit enforcement isn't "
|
|
429
|
+
"available here. Omit --forbid-commit (the child may commit), or run "
|
|
430
|
+
"from a Git workspace.",
|
|
431
|
+
)
|
|
432
|
+
if isolation_context is None or isolation_context.isolation_lifecycle != "persistent":
|
|
433
|
+
raise DelegateError(
|
|
434
|
+
"invalid_option_combination",
|
|
435
|
+
"--forbid-commit requires --isolation worktree so Delegate can enforce "
|
|
436
|
+
"the policy — add --isolation worktree, or omit --forbid-commit.",
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _forbid_commit_implied_isolation_note() -> str:
|
|
441
|
+
"""The single source of the --forbid-commit implies --isolation worktree note."""
|
|
442
|
+
return "note: --forbid-commit implies --isolation worktree"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _apply_forbid_commit_isolation_implication(
|
|
446
|
+
*,
|
|
447
|
+
forbid_commit: bool,
|
|
448
|
+
mode: str,
|
|
449
|
+
cli_isolation: str | None,
|
|
450
|
+
json_isolation: str | None,
|
|
451
|
+
) -> tuple[str | None, str | None, bool]:
|
|
452
|
+
"""Apply the forbid-commit isolation implication shared by CLI and input-json paths.
|
|
453
|
+
|
|
454
|
+
Returns ``(resolved_json_isolation, note, implied)``:
|
|
455
|
+
- When forbid-commit is active in work mode with no explicit isolation, the
|
|
456
|
+
implied worktree isolation is returned for the JSON path (the CLI path
|
|
457
|
+
sets this in the parser), plus the note, and ``implied=True``.
|
|
458
|
+
- When forbid-commit is active in work mode with explicit ``none``,
|
|
459
|
+
an ``invalid_option_combination`` error is raised (both paths share this).
|
|
460
|
+
- Otherwise the inputs are returned unchanged with ``implied=False``.
|
|
461
|
+
|
|
462
|
+
This is the single place that owns the implication so ``run --input-json``
|
|
463
|
+
with ``forbidCommit: true`` and no isolation gets the same implied worktree
|
|
464
|
+
isolation + note as the CLI path.
|
|
465
|
+
"""
|
|
466
|
+
if not forbid_commit or mode != MODE_WORK:
|
|
467
|
+
return json_isolation, None, False
|
|
468
|
+
effective = cli_isolation if cli_isolation is not None else json_isolation
|
|
469
|
+
if effective is None:
|
|
470
|
+
return "worktree", _forbid_commit_implied_isolation_note(), True
|
|
471
|
+
if effective == "none":
|
|
472
|
+
raise DelegateError(
|
|
473
|
+
"invalid_option_combination",
|
|
474
|
+
"--forbid-commit cannot be combined with --isolation none. "
|
|
475
|
+
"Use --isolation worktree, or omit --forbid-commit.",
|
|
476
|
+
)
|
|
477
|
+
return json_isolation, None, False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _validate_droid_model_alias(config: JsonObject, model_alias: str | None) -> None:
|
|
481
|
+
models = config["droid"]["models"]
|
|
482
|
+
if model_alias is None or model_alias not in models:
|
|
483
|
+
raise DelegateError("invalid_alias", f"Unknown Droid model alias: {model_alias}")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _validate_include_dirty(
|
|
487
|
+
*,
|
|
488
|
+
include_dirty: bool,
|
|
489
|
+
mode: str,
|
|
490
|
+
isolation_context: IsolationContext | None,
|
|
491
|
+
) -> None:
|
|
492
|
+
if not include_dirty:
|
|
493
|
+
return
|
|
494
|
+
if mode != MODE_WORK or isolation_context is None:
|
|
495
|
+
raise DelegateError(
|
|
496
|
+
"invalid_option_combination",
|
|
497
|
+
"--include-dirty requires work mode with --isolation worktree.",
|
|
498
|
+
)
|
|
499
|
+
if isolation_context.isolation_lifecycle != "persistent":
|
|
500
|
+
raise DelegateError(
|
|
501
|
+
"invalid_option_combination",
|
|
502
|
+
"--include-dirty requires work mode with --isolation worktree.",
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _call_workspace(dry_run: bool) -> tuple[ResolvedWorkspace, bool]:
|
|
507
|
+
if dry_run:
|
|
508
|
+
return ResolvedWorkspace(CALL_TEMP_CWD_PLACEHOLDER, "directory"), False
|
|
509
|
+
temp_dir = tempfile.mkdtemp(prefix="delegate-call-")
|
|
510
|
+
return ResolvedWorkspace(temp_dir, "directory"), True
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _validate_call_cli_options(global_options: object, launch: object) -> None:
|
|
514
|
+
cwd = getattr(global_options, "cwd", None)
|
|
515
|
+
isolation = getattr(global_options, "isolation", None)
|
|
516
|
+
pass_through = getattr(global_options, "pass_through", False)
|
|
517
|
+
completion_report = getattr(global_options, "completion_report", None)
|
|
518
|
+
progress_intent = getattr(launch, "progress_intent", None)
|
|
519
|
+
forbid_commit = getattr(launch, "forbid_commit", False)
|
|
520
|
+
include_dirty = getattr(launch, "include_dirty", False)
|
|
521
|
+
if cwd is not None:
|
|
522
|
+
raise DelegateError("invalid_option_combination", "call mode does not use --cwd.")
|
|
523
|
+
if isolation is not None:
|
|
524
|
+
raise DelegateError("invalid_option_combination", "call mode does not use --isolation.")
|
|
525
|
+
if pass_through:
|
|
526
|
+
raise DelegateError(
|
|
527
|
+
"invalid_option_combination",
|
|
528
|
+
"--pass-through is not supported with call mode; call already returns synchronously.",
|
|
529
|
+
)
|
|
530
|
+
if completion_report == delegate_config.COMPLETION_REPORT_MODE_MARKDOWN:
|
|
531
|
+
raise DelegateError(
|
|
532
|
+
"invalid_option_combination",
|
|
533
|
+
"--completion-report is not supported with call mode.",
|
|
534
|
+
)
|
|
535
|
+
if progress_intent == "on":
|
|
536
|
+
raise DelegateError(
|
|
537
|
+
"invalid_option_combination", "--progress is not supported with call mode."
|
|
538
|
+
)
|
|
539
|
+
if forbid_commit:
|
|
540
|
+
raise DelegateError(
|
|
541
|
+
"invalid_option_combination",
|
|
542
|
+
"--forbid-commit requires work mode with persistent worktree isolation.",
|
|
543
|
+
)
|
|
544
|
+
if include_dirty:
|
|
545
|
+
raise DelegateError(
|
|
546
|
+
"invalid_option_combination",
|
|
547
|
+
"--include-dirty requires work mode with persistent worktree isolation.",
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _validate_call_input_json_options(
|
|
552
|
+
global_options: object,
|
|
553
|
+
raw: JsonObject,
|
|
554
|
+
*,
|
|
555
|
+
raw_progress_intent: ProgressIntent,
|
|
556
|
+
raw_forbid_commit: bool,
|
|
557
|
+
raw_include_dirty: bool,
|
|
558
|
+
) -> None:
|
|
559
|
+
if getattr(global_options, "cwd", None) is not None:
|
|
560
|
+
raise DelegateError("invalid_option_combination", "call mode does not use --cwd.")
|
|
561
|
+
if getattr(global_options, "isolation", None) is not None:
|
|
562
|
+
raise DelegateError("invalid_option_combination", "call mode does not use --isolation.")
|
|
563
|
+
if getattr(global_options, "pass_through", False):
|
|
564
|
+
raise DelegateError(
|
|
565
|
+
"invalid_option_combination",
|
|
566
|
+
"--pass-through is not supported with call mode; call already returns synchronously.",
|
|
567
|
+
)
|
|
568
|
+
if (
|
|
569
|
+
getattr(global_options, "completion_report", None)
|
|
570
|
+
== delegate_config.COMPLETION_REPORT_MODE_MARKDOWN
|
|
571
|
+
):
|
|
572
|
+
raise DelegateError(
|
|
573
|
+
"invalid_option_combination",
|
|
574
|
+
"--completion-report is not supported with call mode.",
|
|
575
|
+
)
|
|
576
|
+
if raw.get("cwd") is not None:
|
|
577
|
+
raise DelegateError(
|
|
578
|
+
"invalid_option_combination", "call mode input JSON must not include cwd."
|
|
579
|
+
)
|
|
580
|
+
if "isolation" in raw:
|
|
581
|
+
raise DelegateError(
|
|
582
|
+
"invalid_option_combination",
|
|
583
|
+
"call mode input JSON must not include isolation.",
|
|
584
|
+
)
|
|
585
|
+
if raw_progress_intent == "on":
|
|
586
|
+
raise DelegateError(
|
|
587
|
+
"invalid_option_combination", "progress is not supported with call mode."
|
|
588
|
+
)
|
|
589
|
+
if raw_forbid_commit:
|
|
590
|
+
raise DelegateError(
|
|
591
|
+
"invalid_option_combination",
|
|
592
|
+
"forbidCommit requires work mode with persistent worktree isolation.",
|
|
593
|
+
)
|
|
594
|
+
if raw_include_dirty:
|
|
595
|
+
raise DelegateError(
|
|
596
|
+
"invalid_option_combination",
|
|
597
|
+
"includeDirty requires work mode with persistent worktree isolation.",
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _safe_none_normalization_warnings(
|
|
602
|
+
*,
|
|
603
|
+
engine: str,
|
|
604
|
+
mode: str,
|
|
605
|
+
requested: str | None,
|
|
606
|
+
effective: str,
|
|
607
|
+
) -> tuple[str, ...]:
|
|
608
|
+
if (
|
|
609
|
+
mode == MODE_SAFE
|
|
610
|
+
and requested == delegate_config.ISOLATION_NONE
|
|
611
|
+
and effective == delegate_config.ISOLATION_AUTO
|
|
612
|
+
and engine in delegate_config.SAFE_ISOLATION_REQUIRED_ENGINES
|
|
613
|
+
):
|
|
614
|
+
return (
|
|
615
|
+
f"isolation none is not used for {engine} safe mode; using auto "
|
|
616
|
+
"temporary isolation instead.",
|
|
617
|
+
)
|
|
618
|
+
return ()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def request_from_parsed(parsed: ParsedCommand, config: JsonObject, stdin: TextIO) -> Request:
|
|
622
|
+
validate_config(config)
|
|
623
|
+
if parsed.subcommand == "run":
|
|
624
|
+
return request_from_input_json(parsed, config)
|
|
625
|
+
launch = parsed.launch
|
|
626
|
+
global_options = parsed.global_options
|
|
627
|
+
if launch is None or launch.engine not in KNOWN_ENGINES:
|
|
628
|
+
raise DelegateError("invalid_command", "Command does not map to an execution request.")
|
|
629
|
+
if launch.mode is None:
|
|
630
|
+
raise DelegateError("invalid_command", "Command does not map to an execution request.")
|
|
631
|
+
if launch.mode == MODE_CALL:
|
|
632
|
+
_validate_call_cli_options(global_options, launch)
|
|
633
|
+
read_only = getattr(launch, "read_only", False)
|
|
634
|
+
if launch.engine == "droid":
|
|
635
|
+
_validate_droid_model_alias(config, launch.model_alias)
|
|
636
|
+
output_schema = resolve_output_schema(launch.engine, launch.output_schema)
|
|
637
|
+
prompt = _call_effective_prompt(
|
|
638
|
+
resolve_prompt(launch.prompt_parts, launch.prompt_file, stdin),
|
|
639
|
+
read_only=read_only,
|
|
640
|
+
)
|
|
641
|
+
workspace, cleanup_workspace = _call_workspace(launch.dry_run)
|
|
642
|
+
try:
|
|
643
|
+
return build_request(
|
|
644
|
+
launch.engine,
|
|
645
|
+
launch.mode,
|
|
646
|
+
launch.model_alias,
|
|
647
|
+
workspace,
|
|
648
|
+
prompt,
|
|
649
|
+
config,
|
|
650
|
+
launch.dry_run,
|
|
651
|
+
stream_capture=True,
|
|
652
|
+
isolation_context=None,
|
|
653
|
+
reasoning_effort=launch.reasoning_effort,
|
|
654
|
+
reasoning_effort_source="cli" if launch.reasoning_effort is not None else None,
|
|
655
|
+
progress=False,
|
|
656
|
+
forbid_commit=False,
|
|
657
|
+
auth_profile_override=global_options.auth_profile,
|
|
658
|
+
output_schema=output_schema,
|
|
659
|
+
cleanup_workspace=cleanup_workspace,
|
|
660
|
+
call_read_only=read_only,
|
|
661
|
+
group=global_options.group,
|
|
662
|
+
)
|
|
663
|
+
except BaseException:
|
|
664
|
+
if cleanup_workspace:
|
|
665
|
+
shutil.rmtree(workspace.path, ignore_errors=True)
|
|
666
|
+
raise
|
|
667
|
+
if getattr(launch, "read_only", False):
|
|
668
|
+
raise DelegateError(
|
|
669
|
+
"invalid_option_combination",
|
|
670
|
+
"--read-only only applies to call mode.",
|
|
671
|
+
)
|
|
672
|
+
effective_progress = resolve_effective_progress(launch.progress_intent, config)
|
|
673
|
+
if effective_progress and global_options.pass_through:
|
|
674
|
+
raise DelegateError(
|
|
675
|
+
"invalid_option_combination",
|
|
676
|
+
"--progress is incompatible with --pass-through.",
|
|
677
|
+
)
|
|
678
|
+
progress_initial_delay_sec, progress_interval_sec = resolve_progress_timing(config)
|
|
679
|
+
output_schema = resolve_output_schema(launch.engine, launch.output_schema)
|
|
680
|
+
workspace = resolve_workspace(global_options.cwd)
|
|
681
|
+
prompt = resolve_prompt(launch.prompt_parts, launch.prompt_file, stdin)
|
|
682
|
+
|
|
683
|
+
# Capture git metadata for isolation planning (read-only, safe in dry-run too).
|
|
684
|
+
git_root, git_common_dir, git_head_oid, git_head_ref, git_branch = capture_git_metadata(
|
|
685
|
+
workspace.path
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
# Resolve effective isolation and build isolation context.
|
|
689
|
+
try:
|
|
690
|
+
effective_isolation = delegate_config.resolve_isolation(
|
|
691
|
+
cli_value=global_options.isolation,
|
|
692
|
+
loaded_config=config,
|
|
693
|
+
engine=launch.engine,
|
|
694
|
+
mode=launch.mode,
|
|
695
|
+
)
|
|
696
|
+
except delegate_config.InvalidIsolationError as exc:
|
|
697
|
+
raise DelegateError("invalid_isolation", str(exc)) from exc
|
|
698
|
+
isolation_warnings = list(
|
|
699
|
+
_safe_none_normalization_warnings(
|
|
700
|
+
engine=launch.engine,
|
|
701
|
+
mode=launch.mode,
|
|
702
|
+
requested=global_options.isolation,
|
|
703
|
+
effective=effective_isolation,
|
|
704
|
+
)
|
|
705
|
+
)
|
|
706
|
+
if getattr(launch, "forbid_commit_implied_isolation", False):
|
|
707
|
+
isolation_warnings.append(_forbid_commit_implied_isolation_note())
|
|
708
|
+
|
|
709
|
+
isolation_context = build_isolation_context(
|
|
710
|
+
source_workspace=workspace.path,
|
|
711
|
+
resolved_isolation=effective_isolation,
|
|
712
|
+
engine=launch.engine,
|
|
713
|
+
mode=launch.mode,
|
|
714
|
+
model_alias=launch.model_alias,
|
|
715
|
+
config=config,
|
|
716
|
+
run_short_id="<short-run-id-placeholder>" if launch.dry_run else None,
|
|
717
|
+
source_git_root=git_root,
|
|
718
|
+
source_git_common_dir=git_common_dir,
|
|
719
|
+
source_head_oid=git_head_oid,
|
|
720
|
+
source_head_ref=git_head_ref,
|
|
721
|
+
source_branch=git_branch,
|
|
722
|
+
include_dirty=launch.include_dirty,
|
|
723
|
+
)
|
|
724
|
+
_validate_forbid_commit(
|
|
725
|
+
forbid_commit=launch.forbid_commit,
|
|
726
|
+
mode=launch.mode,
|
|
727
|
+
isolation_context=isolation_context,
|
|
728
|
+
)
|
|
729
|
+
_validate_include_dirty(
|
|
730
|
+
include_dirty=launch.include_dirty,
|
|
731
|
+
mode=launch.mode,
|
|
732
|
+
isolation_context=isolation_context,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
completion_report_mode = resolve_completion_report_mode(parsed, config)
|
|
736
|
+
completion_report_prompt_mode, output_schema_warnings = _completion_report_prompt_mode(
|
|
737
|
+
completion_report_mode,
|
|
738
|
+
output_schema,
|
|
739
|
+
)
|
|
740
|
+
prompt = effective_prompt(
|
|
741
|
+
prompt,
|
|
742
|
+
engine=launch.engine,
|
|
743
|
+
mode=launch.mode,
|
|
744
|
+
completion_report_mode=completion_report_prompt_mode,
|
|
745
|
+
)
|
|
746
|
+
return build_request(
|
|
747
|
+
launch.engine,
|
|
748
|
+
launch.mode,
|
|
749
|
+
launch.model_alias,
|
|
750
|
+
workspace,
|
|
751
|
+
prompt,
|
|
752
|
+
config,
|
|
753
|
+
launch.dry_run,
|
|
754
|
+
stream_capture=not global_options.pass_through,
|
|
755
|
+
isolation_context=isolation_context,
|
|
756
|
+
reasoning_effort=launch.reasoning_effort,
|
|
757
|
+
reasoning_effort_source="cli" if launch.reasoning_effort is not None else None,
|
|
758
|
+
progress=effective_progress,
|
|
759
|
+
progress_initial_delay_sec=progress_initial_delay_sec,
|
|
760
|
+
progress_interval_sec=progress_interval_sec,
|
|
761
|
+
forbid_commit=launch.forbid_commit,
|
|
762
|
+
include_dirty=launch.include_dirty,
|
|
763
|
+
auth_profile_override=global_options.auth_profile,
|
|
764
|
+
output_schema=output_schema,
|
|
765
|
+
warnings=(*output_schema_warnings, *isolation_warnings),
|
|
766
|
+
group=global_options.group,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _load_input_json_object(path: Path) -> JsonObject:
|
|
771
|
+
try:
|
|
772
|
+
raw: JsonValue = json.loads(path.read_text(encoding="utf-8"))
|
|
773
|
+
except FileNotFoundError:
|
|
774
|
+
raise DelegateError("input_json_not_found", f"Input JSON file not found: {path}") from None
|
|
775
|
+
except json.JSONDecodeError as exc:
|
|
776
|
+
raise DelegateError("invalid_input_json", f"Invalid input JSON: {exc}") from exc
|
|
777
|
+
if not isinstance(raw, dict):
|
|
778
|
+
raise DelegateError("invalid_input_json", "Input JSON root must be an object.")
|
|
779
|
+
return raw
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def request_from_input_json(parsed: ParsedCommand, config: JsonObject) -> Request:
|
|
783
|
+
run_json = parsed.run_json
|
|
784
|
+
if run_json is None:
|
|
785
|
+
raise DelegateError("invalid_command", "run --input-json options are required.")
|
|
786
|
+
global_options = parsed.global_options
|
|
787
|
+
_reject_windows_path(run_json.input_json, "--input-json")
|
|
788
|
+
path = Path(run_json.input_json).expanduser()
|
|
789
|
+
raw = _load_input_json_object(path)
|
|
790
|
+
|
|
791
|
+
if "profile" in raw:
|
|
792
|
+
raise DelegateError(
|
|
793
|
+
"invalid_input_key",
|
|
794
|
+
"Input JSON must not include profile; set codex.profile in config for Codex.",
|
|
795
|
+
)
|
|
796
|
+
unknown = sorted(set(raw) - RUN_INPUT_KEYS)
|
|
797
|
+
if unknown:
|
|
798
|
+
raise DelegateError("unknown_input_key", f"Unknown input JSON keys: {', '.join(unknown)}")
|
|
799
|
+
engine = raw.get("engine")
|
|
800
|
+
mode = raw.get("mode")
|
|
801
|
+
prompt = raw.get("prompt")
|
|
802
|
+
if engine not in KNOWN_ENGINES:
|
|
803
|
+
raise DelegateError(
|
|
804
|
+
"invalid_engine",
|
|
805
|
+
f"engine must be {ENGINES_PROSE}.",
|
|
806
|
+
)
|
|
807
|
+
if not isinstance(mode, str):
|
|
808
|
+
raise DelegateError("invalid_mode", "mode must be safe, work, or call.")
|
|
809
|
+
validate_mode(mode)
|
|
810
|
+
if not isinstance(prompt, str):
|
|
811
|
+
raise DelegateError("invalid_prompt", "prompt must be a string.")
|
|
812
|
+
model_alias = raw.get("model")
|
|
813
|
+
raw_reasoning_effort = raw.get("reasoningEffort")
|
|
814
|
+
if raw_reasoning_effort is not None:
|
|
815
|
+
try:
|
|
816
|
+
reasoning_effort = reasoning.normalize_effort(raw_reasoning_effort)
|
|
817
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
818
|
+
raise DelegateError(exc.error, "reasoningEffort must be a non-empty string.") from exc
|
|
819
|
+
else:
|
|
820
|
+
reasoning_effort = None
|
|
821
|
+
raw_progress_intent: ProgressIntent
|
|
822
|
+
if "progress" in raw:
|
|
823
|
+
raw_progress = raw["progress"]
|
|
824
|
+
if not isinstance(raw_progress, bool):
|
|
825
|
+
raise DelegateError("invalid_progress", "progress must be true or false.")
|
|
826
|
+
raw_progress_intent = "on" if raw_progress else "off"
|
|
827
|
+
else:
|
|
828
|
+
raw_progress_intent = None
|
|
829
|
+
effective_progress = resolve_effective_progress(raw_progress_intent, config)
|
|
830
|
+
if effective_progress and global_options.pass_through:
|
|
831
|
+
raise DelegateError(
|
|
832
|
+
"invalid_option_combination",
|
|
833
|
+
"progress is incompatible with --pass-through.",
|
|
834
|
+
)
|
|
835
|
+
progress_initial_delay_sec, progress_interval_sec = resolve_progress_timing(config)
|
|
836
|
+
raw_forbid_commit = raw.get("forbidCommit", False)
|
|
837
|
+
if not isinstance(raw_forbid_commit, bool):
|
|
838
|
+
raise DelegateError("invalid_forbid_commit", "forbidCommit must be true or false.")
|
|
839
|
+
raw_read_only = raw.get("readOnly", False)
|
|
840
|
+
if not isinstance(raw_read_only, bool):
|
|
841
|
+
raise DelegateError("invalid_read_only", "readOnly must be true or false.")
|
|
842
|
+
if raw_read_only and mode != MODE_CALL:
|
|
843
|
+
raise DelegateError(
|
|
844
|
+
"invalid_option_combination",
|
|
845
|
+
"readOnly only applies to call mode.",
|
|
846
|
+
)
|
|
847
|
+
raw_include_dirty = raw.get("includeDirty", False)
|
|
848
|
+
if not isinstance(raw_include_dirty, bool):
|
|
849
|
+
raise DelegateError("invalid_include_dirty", "includeDirty must be true or false.")
|
|
850
|
+
if engine == "droid":
|
|
851
|
+
if not isinstance(model_alias, str) or not model_alias:
|
|
852
|
+
raise DelegateError("missing_model", "droid run input requires model alias.")
|
|
853
|
+
elif engine in MODELESS_NONCURSOR_ENGINES:
|
|
854
|
+
if model_alias is not None and not isinstance(model_alias, str):
|
|
855
|
+
raise DelegateError("invalid_model", f"model must be a string or null for {engine}.")
|
|
856
|
+
if model_alias == "":
|
|
857
|
+
raise DelegateError(
|
|
858
|
+
"invalid_model", f"model must be a non-empty string or omitted for {engine}."
|
|
859
|
+
)
|
|
860
|
+
elif model_alias is not None and model_alias != config["cursor"]["defaultModel"]:
|
|
861
|
+
raise DelegateError(
|
|
862
|
+
"invalid_model", "cursor model override must match configured Composer model."
|
|
863
|
+
)
|
|
864
|
+
output_schema = resolve_output_schema(str(engine), raw.get("outputSchema"))
|
|
865
|
+
|
|
866
|
+
if mode == MODE_CALL:
|
|
867
|
+
_validate_call_input_json_options(
|
|
868
|
+
global_options,
|
|
869
|
+
raw,
|
|
870
|
+
raw_progress_intent=raw_progress_intent,
|
|
871
|
+
raw_forbid_commit=raw_forbid_commit,
|
|
872
|
+
raw_include_dirty=raw_include_dirty,
|
|
873
|
+
)
|
|
874
|
+
if engine == "droid":
|
|
875
|
+
_validate_droid_model_alias(config, model_alias)
|
|
876
|
+
workspace, cleanup_workspace = _call_workspace(False)
|
|
877
|
+
try:
|
|
878
|
+
return build_request(
|
|
879
|
+
str(engine),
|
|
880
|
+
str(mode),
|
|
881
|
+
model_alias,
|
|
882
|
+
workspace,
|
|
883
|
+
_call_effective_prompt(validate_prompt(prompt), read_only=raw_read_only),
|
|
884
|
+
config,
|
|
885
|
+
dry_run=False,
|
|
886
|
+
stream_capture=True,
|
|
887
|
+
isolation_context=None,
|
|
888
|
+
reasoning_effort=reasoning_effort,
|
|
889
|
+
reasoning_effort_source="input-json" if reasoning_effort is not None else None,
|
|
890
|
+
progress=False,
|
|
891
|
+
forbid_commit=False,
|
|
892
|
+
auth_profile_override=global_options.auth_profile,
|
|
893
|
+
output_schema=output_schema,
|
|
894
|
+
cleanup_workspace=cleanup_workspace,
|
|
895
|
+
call_read_only=raw_read_only,
|
|
896
|
+
group=global_options.group,
|
|
897
|
+
)
|
|
898
|
+
except BaseException:
|
|
899
|
+
if cleanup_workspace:
|
|
900
|
+
shutil.rmtree(workspace.path, ignore_errors=True)
|
|
901
|
+
raise
|
|
902
|
+
|
|
903
|
+
# Pre-read cwd and isolation from JSON for config discovery (already done in main() for
|
|
904
|
+
# config loading, but re-validate and resolve here for the request).
|
|
905
|
+
json_cwd = raw.get("cwd")
|
|
906
|
+
if json_cwd is not None and not isinstance(json_cwd, str):
|
|
907
|
+
raise DelegateError("invalid_cwd", "cwd must be a string.")
|
|
908
|
+
|
|
909
|
+
# Reject explicit null isolation in the JSON (distinguish missing-key from null).
|
|
910
|
+
if "isolation" in raw and raw["isolation"] is None:
|
|
911
|
+
raise DelegateError(
|
|
912
|
+
"invalid_isolation",
|
|
913
|
+
"isolation in input JSON must be auto, none, or worktree (null is not allowed).",
|
|
914
|
+
)
|
|
915
|
+
json_isolation = raw.get("isolation")
|
|
916
|
+
if json_isolation is not None and json_isolation not in delegate_config.VALID_ISOLATION_VALUES:
|
|
917
|
+
raise DelegateError(
|
|
918
|
+
"invalid_isolation",
|
|
919
|
+
"isolation in input JSON must be auto, none, or worktree.",
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
# Apply the forbid-commit isolation implication shared with the CLI path so
|
|
923
|
+
# run --input-json with forbidCommit: true and no isolation gets the same
|
|
924
|
+
# implied worktree isolation + note. Explicit "none" + forbidCommit errors
|
|
925
|
+
# here (both paths share this refusal).
|
|
926
|
+
forbid_commit_note: str | None = None
|
|
927
|
+
if raw_forbid_commit:
|
|
928
|
+
json_isolation, forbid_commit_note, _ = _apply_forbid_commit_isolation_implication(
|
|
929
|
+
forbid_commit=raw_forbid_commit,
|
|
930
|
+
mode=str(mode),
|
|
931
|
+
cli_isolation=global_options.isolation,
|
|
932
|
+
json_isolation=json_isolation,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
workspace = resolve_workspace(global_options.cwd, json_cwd)
|
|
936
|
+
git_root, git_common_dir, git_head_oid, git_head_ref, git_branch = capture_git_metadata(
|
|
937
|
+
workspace.path
|
|
938
|
+
)
|
|
939
|
+
|
|
940
|
+
try:
|
|
941
|
+
effective_isolation = delegate_config.resolve_isolation(
|
|
942
|
+
cli_value=global_options.isolation,
|
|
943
|
+
input_json_value=json_isolation,
|
|
944
|
+
loaded_config=config,
|
|
945
|
+
engine=str(engine),
|
|
946
|
+
mode=str(mode),
|
|
947
|
+
)
|
|
948
|
+
except delegate_config.InvalidIsolationError as exc:
|
|
949
|
+
raise DelegateError("invalid_isolation", str(exc)) from exc
|
|
950
|
+
isolation_warnings = list(
|
|
951
|
+
_safe_none_normalization_warnings(
|
|
952
|
+
engine=str(engine),
|
|
953
|
+
mode=str(mode),
|
|
954
|
+
requested=global_options.isolation or json_isolation,
|
|
955
|
+
effective=effective_isolation,
|
|
956
|
+
)
|
|
957
|
+
)
|
|
958
|
+
if forbid_commit_note is not None:
|
|
959
|
+
isolation_warnings.append(forbid_commit_note)
|
|
960
|
+
|
|
961
|
+
isolation_context = build_isolation_context(
|
|
962
|
+
source_workspace=workspace.path,
|
|
963
|
+
resolved_isolation=effective_isolation,
|
|
964
|
+
engine=str(engine),
|
|
965
|
+
mode=str(mode),
|
|
966
|
+
model_alias=model_alias,
|
|
967
|
+
config=config,
|
|
968
|
+
run_short_id=None,
|
|
969
|
+
source_git_root=git_root,
|
|
970
|
+
source_git_common_dir=git_common_dir,
|
|
971
|
+
source_head_oid=git_head_oid,
|
|
972
|
+
source_head_ref=git_head_ref,
|
|
973
|
+
source_branch=git_branch,
|
|
974
|
+
include_dirty=raw_include_dirty,
|
|
975
|
+
)
|
|
976
|
+
_validate_forbid_commit(
|
|
977
|
+
forbid_commit=raw_forbid_commit,
|
|
978
|
+
mode=str(mode),
|
|
979
|
+
isolation_context=isolation_context,
|
|
980
|
+
)
|
|
981
|
+
_validate_include_dirty(
|
|
982
|
+
include_dirty=raw_include_dirty,
|
|
983
|
+
mode=str(mode),
|
|
984
|
+
isolation_context=isolation_context,
|
|
985
|
+
)
|
|
986
|
+
if engine == "droid":
|
|
987
|
+
_validate_droid_model_alias(config, model_alias)
|
|
988
|
+
|
|
989
|
+
completion_report_mode = resolve_completion_report_mode(parsed, config)
|
|
990
|
+
completion_report_prompt_mode, output_schema_warnings = _completion_report_prompt_mode(
|
|
991
|
+
completion_report_mode,
|
|
992
|
+
output_schema,
|
|
993
|
+
)
|
|
994
|
+
prompt = effective_prompt(
|
|
995
|
+
validate_prompt(prompt),
|
|
996
|
+
engine=str(engine),
|
|
997
|
+
mode=str(mode),
|
|
998
|
+
completion_report_mode=completion_report_prompt_mode,
|
|
999
|
+
)
|
|
1000
|
+
return build_request(
|
|
1001
|
+
str(engine),
|
|
1002
|
+
str(mode),
|
|
1003
|
+
model_alias,
|
|
1004
|
+
workspace,
|
|
1005
|
+
prompt,
|
|
1006
|
+
config,
|
|
1007
|
+
dry_run=False,
|
|
1008
|
+
stream_capture=not global_options.pass_through,
|
|
1009
|
+
isolation_context=isolation_context,
|
|
1010
|
+
reasoning_effort=reasoning_effort,
|
|
1011
|
+
reasoning_effort_source="input-json" if reasoning_effort is not None else None,
|
|
1012
|
+
progress=effective_progress,
|
|
1013
|
+
progress_initial_delay_sec=progress_initial_delay_sec,
|
|
1014
|
+
progress_interval_sec=progress_interval_sec,
|
|
1015
|
+
forbid_commit=raw_forbid_commit,
|
|
1016
|
+
include_dirty=raw_include_dirty,
|
|
1017
|
+
auth_profile_override=global_options.auth_profile,
|
|
1018
|
+
output_schema=output_schema,
|
|
1019
|
+
warnings=(*output_schema_warnings, *isolation_warnings),
|
|
1020
|
+
group=global_options.group,
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def build_request(
|
|
1025
|
+
engine: str,
|
|
1026
|
+
mode: str,
|
|
1027
|
+
model_alias: str | None,
|
|
1028
|
+
workspace: ResolvedWorkspace,
|
|
1029
|
+
prompt: str,
|
|
1030
|
+
config: JsonObject,
|
|
1031
|
+
dry_run: bool,
|
|
1032
|
+
*,
|
|
1033
|
+
stream_capture: bool = True,
|
|
1034
|
+
isolation_context: IsolationContext | None = None,
|
|
1035
|
+
reasoning_effort: str | None = None,
|
|
1036
|
+
reasoning_effort_source: str | None = None,
|
|
1037
|
+
progress: bool = False,
|
|
1038
|
+
progress_initial_delay_sec: float = delegate_runner.PROGRESS_INITIAL_DELAY_SEC,
|
|
1039
|
+
progress_interval_sec: float = delegate_runner.PROGRESS_HEARTBEAT_INTERVAL_SEC,
|
|
1040
|
+
forbid_commit: bool = False,
|
|
1041
|
+
include_dirty: bool = False,
|
|
1042
|
+
auth_profile_override: str | None = None,
|
|
1043
|
+
output_schema: str | None = None,
|
|
1044
|
+
warnings: tuple[str, ...] = (),
|
|
1045
|
+
cleanup_workspace: bool = False,
|
|
1046
|
+
call_read_only: bool = False,
|
|
1047
|
+
group: str | None = None,
|
|
1048
|
+
) -> Request:
|
|
1049
|
+
if not isinstance(workspace, ResolvedWorkspace):
|
|
1050
|
+
raise TypeError(
|
|
1051
|
+
"build_request requires a ResolvedWorkspace; "
|
|
1052
|
+
"wrap raw Git workspace paths with ResolvedWorkspace(path, 'git')."
|
|
1053
|
+
)
|
|
1054
|
+
requested_effort, effort_source = resolve_effective_reasoning_effort(
|
|
1055
|
+
config,
|
|
1056
|
+
engine,
|
|
1057
|
+
reasoning_effort,
|
|
1058
|
+
reasoning_effort_source=reasoning_effort_source,
|
|
1059
|
+
)
|
|
1060
|
+
output_schema = resolve_output_schema(engine, output_schema)
|
|
1061
|
+
|
|
1062
|
+
return _build_request_for_workspace(
|
|
1063
|
+
engine,
|
|
1064
|
+
mode,
|
|
1065
|
+
model_alias,
|
|
1066
|
+
workspace,
|
|
1067
|
+
prompt,
|
|
1068
|
+
config,
|
|
1069
|
+
dry_run,
|
|
1070
|
+
stream_capture=stream_capture,
|
|
1071
|
+
isolation_context=isolation_context,
|
|
1072
|
+
requested_effort=requested_effort,
|
|
1073
|
+
effort_source=effort_source,
|
|
1074
|
+
progress=progress,
|
|
1075
|
+
progress_initial_delay_sec=progress_initial_delay_sec,
|
|
1076
|
+
progress_interval_sec=progress_interval_sec,
|
|
1077
|
+
forbid_commit=forbid_commit,
|
|
1078
|
+
include_dirty=include_dirty,
|
|
1079
|
+
auth_profile_override=auth_profile_override,
|
|
1080
|
+
output_schema=output_schema,
|
|
1081
|
+
warnings=warnings,
|
|
1082
|
+
cleanup_workspace=cleanup_workspace,
|
|
1083
|
+
call_read_only=call_read_only,
|
|
1084
|
+
group=group,
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _cursor_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1089
|
+
_ = build.model_alias, build.cache
|
|
1090
|
+
cursor = build.config["cursor"]
|
|
1091
|
+
capability, reasoning_warnings = _capability_with_config_fallback(
|
|
1092
|
+
lambda: resolve_cursor_reasoning_capability(cursor, build.requested_effort),
|
|
1093
|
+
engine="cursor",
|
|
1094
|
+
effort_source=build.effort_source,
|
|
1095
|
+
)
|
|
1096
|
+
model = capability.model if capability is not None else cursor["defaultModel"]
|
|
1097
|
+
argv = build_cursor_argv(
|
|
1098
|
+
cursor["argvPrefix"],
|
|
1099
|
+
build.mode,
|
|
1100
|
+
build.resolved.path,
|
|
1101
|
+
model,
|
|
1102
|
+
build.prompt,
|
|
1103
|
+
stream_capture=build.stream_capture,
|
|
1104
|
+
call_read_only=build.call_read_only,
|
|
1105
|
+
)
|
|
1106
|
+
return EngineRequestParts(
|
|
1107
|
+
model=model,
|
|
1108
|
+
argv=argv,
|
|
1109
|
+
model_alias=None,
|
|
1110
|
+
prompt_transport=PROMPT_TRANSPORT_ARGV,
|
|
1111
|
+
display_argv=redacted_prompt_argv(argv),
|
|
1112
|
+
warnings=reasoning_warnings,
|
|
1113
|
+
**reasoning_request_kwargs(capability, build.effort_source),
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _droid_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1118
|
+
droid = build.config["droid"]
|
|
1119
|
+
models = droid["models"]
|
|
1120
|
+
_validate_droid_model_alias(build.config, build.model_alias)
|
|
1121
|
+
model = models[build.model_alias]
|
|
1122
|
+
if model.startswith("replace-with-") or model in {
|
|
1123
|
+
"your-droid-model-id",
|
|
1124
|
+
"real-droid-model-id",
|
|
1125
|
+
}:
|
|
1126
|
+
raise DelegateError(
|
|
1127
|
+
"unconfigured_model",
|
|
1128
|
+
(
|
|
1129
|
+
f"Droid model alias '{build.model_alias}' is still a placeholder. "
|
|
1130
|
+
"Copy config.example.json to ~/.delegate/config.json and set a real Droid model ID."
|
|
1131
|
+
),
|
|
1132
|
+
)
|
|
1133
|
+
capability, reasoning_warnings = _capability_with_config_fallback(
|
|
1134
|
+
lambda: reasoning.resolve_reasoning_capability(
|
|
1135
|
+
harness="droid",
|
|
1136
|
+
model=model,
|
|
1137
|
+
requested_effort=build.requested_effort,
|
|
1138
|
+
config=build.config,
|
|
1139
|
+
cache=build.cache,
|
|
1140
|
+
alias=build.model_alias,
|
|
1141
|
+
),
|
|
1142
|
+
engine="droid",
|
|
1143
|
+
effort_source=build.effort_source,
|
|
1144
|
+
)
|
|
1145
|
+
prompt = build.prompt
|
|
1146
|
+
if build.mode == MODE_SAFE:
|
|
1147
|
+
prompt = prefix_droid_safe_prompt(prompt)
|
|
1148
|
+
argv = build_droid_argv(
|
|
1149
|
+
droid["binary"],
|
|
1150
|
+
build.mode,
|
|
1151
|
+
build.resolved.path,
|
|
1152
|
+
model,
|
|
1153
|
+
prompt,
|
|
1154
|
+
stream_capture=build.stream_capture,
|
|
1155
|
+
reasoning_capability=capability,
|
|
1156
|
+
prompt_transport=PROMPT_TRANSPORT_FILE,
|
|
1157
|
+
call_read_only=build.call_read_only,
|
|
1158
|
+
)
|
|
1159
|
+
display_argv = [
|
|
1160
|
+
DROID_PROMPT_FILE_DISPLAY if item == DROID_PROMPT_FILE_ARG_PLACEHOLDER else item
|
|
1161
|
+
for item in argv
|
|
1162
|
+
]
|
|
1163
|
+
return EngineRequestParts(
|
|
1164
|
+
model=model,
|
|
1165
|
+
argv=argv,
|
|
1166
|
+
model_alias=build.model_alias,
|
|
1167
|
+
prompt_transport=PROMPT_TRANSPORT_FILE,
|
|
1168
|
+
prompt_file_text=prompt,
|
|
1169
|
+
display_argv=display_argv,
|
|
1170
|
+
warnings=reasoning_warnings,
|
|
1171
|
+
**reasoning_request_kwargs(capability, build.effort_source),
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
def _codex_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1176
|
+
codex = build.config["codex"]
|
|
1177
|
+
if isinstance(build.model_alias, str) and build.model_alias:
|
|
1178
|
+
model = build.model_alias
|
|
1179
|
+
else:
|
|
1180
|
+
model = _resolve_default_model(codex)
|
|
1181
|
+
policy = delegate_config.effective_policy(
|
|
1182
|
+
build.config, engine="codex", mode=_policy_mode(build)
|
|
1183
|
+
)
|
|
1184
|
+
if model is None and build.requested_effort is not None and build.effort_source != "config":
|
|
1185
|
+
effort = reasoning.normalize_effort(build.requested_effort)
|
|
1186
|
+
if effort not in CODEX_HARNESS_DEFAULT_REASONING_EFFORTS:
|
|
1187
|
+
raise DelegateError(
|
|
1188
|
+
"unsupported_reasoning_effort",
|
|
1189
|
+
reasoning.format_explicit_reasoning_effort_error(
|
|
1190
|
+
harness="codex",
|
|
1191
|
+
effort=effort,
|
|
1192
|
+
supported=CODEX_HARNESS_DEFAULT_REASONING_EFFORTS,
|
|
1193
|
+
detail="does not support reasoning effort with the harness default model",
|
|
1194
|
+
),
|
|
1195
|
+
)
|
|
1196
|
+
capability = reasoning.ReasoningCapability(
|
|
1197
|
+
harness="codex",
|
|
1198
|
+
model="",
|
|
1199
|
+
effort=effort,
|
|
1200
|
+
supported_efforts=CODEX_HARNESS_DEFAULT_REASONING_EFFORTS,
|
|
1201
|
+
default_effort=None,
|
|
1202
|
+
transport=reasoning.TRANSPORT_BY_HARNESS["codex"],
|
|
1203
|
+
source="harness-default",
|
|
1204
|
+
)
|
|
1205
|
+
reasoning_warnings = (
|
|
1206
|
+
"codex.defaultModel is unset; applying --reasoning-effort to the Codex "
|
|
1207
|
+
"harness default model.",
|
|
1208
|
+
)
|
|
1209
|
+
else:
|
|
1210
|
+
capability, reasoning_warnings = _capability_with_config_fallback(
|
|
1211
|
+
lambda: reasoning.resolve_reasoning_capability(
|
|
1212
|
+
harness="codex",
|
|
1213
|
+
model=model,
|
|
1214
|
+
requested_effort=build.requested_effort,
|
|
1215
|
+
config=build.config,
|
|
1216
|
+
cache=build.cache,
|
|
1217
|
+
alias=build.model_alias or model,
|
|
1218
|
+
),
|
|
1219
|
+
engine="codex",
|
|
1220
|
+
effort_source=build.effort_source,
|
|
1221
|
+
)
|
|
1222
|
+
argv = build_codex_argv(
|
|
1223
|
+
codex,
|
|
1224
|
+
build.mode,
|
|
1225
|
+
build.resolved.path,
|
|
1226
|
+
model,
|
|
1227
|
+
build.prompt,
|
|
1228
|
+
policy,
|
|
1229
|
+
workspace_kind=build.resolved.kind,
|
|
1230
|
+
stream_capture=build.stream_capture,
|
|
1231
|
+
reasoning_capability=capability,
|
|
1232
|
+
prompt_transport=PROMPT_TRANSPORT_STDIN,
|
|
1233
|
+
output_schema=build.output_schema,
|
|
1234
|
+
call_read_only=build.call_read_only,
|
|
1235
|
+
)
|
|
1236
|
+
return EngineRequestParts(
|
|
1237
|
+
model=model,
|
|
1238
|
+
argv=argv,
|
|
1239
|
+
model_alias=build.model_alias,
|
|
1240
|
+
prompt_transport=PROMPT_TRANSPORT_STDIN,
|
|
1241
|
+
stdin_text=build.prompt,
|
|
1242
|
+
display_argv=list(argv),
|
|
1243
|
+
warnings=reasoning_warnings,
|
|
1244
|
+
**reasoning_request_kwargs(capability, build.effort_source),
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _claude_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1249
|
+
claude = build.config["claude"]
|
|
1250
|
+
if isinstance(build.model_alias, str) and build.model_alias:
|
|
1251
|
+
model = build.model_alias
|
|
1252
|
+
else:
|
|
1253
|
+
model = _resolve_default_model(claude)
|
|
1254
|
+
policy = delegate_config.effective_policy(
|
|
1255
|
+
build.config, engine="claude", mode=_policy_mode(build)
|
|
1256
|
+
)
|
|
1257
|
+
effort: str | None = None
|
|
1258
|
+
warnings: tuple[str, ...] = ()
|
|
1259
|
+
if build.requested_effort is not None:
|
|
1260
|
+
try:
|
|
1261
|
+
effort = reasoning.resolve_claude_native_effort(
|
|
1262
|
+
build.requested_effort,
|
|
1263
|
+
alias=build.model_alias,
|
|
1264
|
+
model=model,
|
|
1265
|
+
)
|
|
1266
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
1267
|
+
if build.effort_source != "config":
|
|
1268
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
1269
|
+
warnings = (f"ignoring claude.defaultReasoningEffort: {exc.message}",)
|
|
1270
|
+
argv = build_claude_argv(
|
|
1271
|
+
claude,
|
|
1272
|
+
build.mode,
|
|
1273
|
+
model,
|
|
1274
|
+
policy,
|
|
1275
|
+
stream_capture=build.stream_capture,
|
|
1276
|
+
reasoning_effort=effort,
|
|
1277
|
+
allow_bypass_permissions=_claude_harness_bypass_enabled(build.config, build.mode),
|
|
1278
|
+
call_read_only=build.call_read_only,
|
|
1279
|
+
)
|
|
1280
|
+
return EngineRequestParts(
|
|
1281
|
+
model=model,
|
|
1282
|
+
argv=argv,
|
|
1283
|
+
model_alias=build.model_alias,
|
|
1284
|
+
prompt_transport=PROMPT_TRANSPORT_STDIN,
|
|
1285
|
+
stdin_text=build.prompt,
|
|
1286
|
+
display_argv=list(argv),
|
|
1287
|
+
warnings=warnings,
|
|
1288
|
+
**claude_reasoning_request_kwargs(effort, build.effort_source),
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _grok_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1293
|
+
grok = build.config["grok"]
|
|
1294
|
+
if isinstance(build.model_alias, str) and build.model_alias:
|
|
1295
|
+
model = build.model_alias
|
|
1296
|
+
else:
|
|
1297
|
+
model = _resolve_default_model(grok)
|
|
1298
|
+
policy = delegate_config.effective_policy(build.config, engine="grok", mode=_policy_mode(build))
|
|
1299
|
+
effort: str | None = None
|
|
1300
|
+
warnings: tuple[str, ...] = ()
|
|
1301
|
+
if build.requested_effort is not None:
|
|
1302
|
+
try:
|
|
1303
|
+
effort = reasoning.resolve_grok_native_effort(
|
|
1304
|
+
build.requested_effort,
|
|
1305
|
+
alias=build.model_alias,
|
|
1306
|
+
model=model,
|
|
1307
|
+
)
|
|
1308
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
1309
|
+
if build.effort_source != "config":
|
|
1310
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
1311
|
+
warnings = (f"ignoring grok.defaultReasoningEffort: {exc.message}",)
|
|
1312
|
+
argv = build_grok_argv(
|
|
1313
|
+
grok,
|
|
1314
|
+
build.mode,
|
|
1315
|
+
build.resolved.path,
|
|
1316
|
+
model,
|
|
1317
|
+
policy,
|
|
1318
|
+
stream_capture=build.stream_capture,
|
|
1319
|
+
reasoning_effort=effort,
|
|
1320
|
+
allow_bypass_permissions=_grok_harness_bypass_enabled(build.config, build.mode),
|
|
1321
|
+
call_read_only=build.call_read_only,
|
|
1322
|
+
)
|
|
1323
|
+
display_argv = [
|
|
1324
|
+
PROMPT_FILE_DISPLAY if item == PROMPT_FILE_ARG_PLACEHOLDER else item for item in argv
|
|
1325
|
+
]
|
|
1326
|
+
return EngineRequestParts(
|
|
1327
|
+
model=model,
|
|
1328
|
+
argv=argv,
|
|
1329
|
+
model_alias=build.model_alias,
|
|
1330
|
+
prompt_transport=PROMPT_TRANSPORT_FILE,
|
|
1331
|
+
prompt_file_text=build.prompt,
|
|
1332
|
+
display_argv=display_argv,
|
|
1333
|
+
warnings=warnings,
|
|
1334
|
+
**grok_reasoning_request_kwargs(effort, build.effort_source),
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
|
|
1338
|
+
def _kimi_request_parts(build: EngineBuildInput) -> EngineRequestParts:
|
|
1339
|
+
_ = build.effort_source, build.cache
|
|
1340
|
+
if build.requested_effort is not None:
|
|
1341
|
+
kimi = build.config.get("kimi")
|
|
1342
|
+
default_model = kimi.get("defaultModel") if isinstance(kimi, dict) else None
|
|
1343
|
+
model = default_model if isinstance(default_model, str) and default_model else None
|
|
1344
|
+
raise DelegateError(
|
|
1345
|
+
"unsupported_reasoning_effort",
|
|
1346
|
+
reasoning.format_explicit_reasoning_effort_error(
|
|
1347
|
+
harness="kimi",
|
|
1348
|
+
alias=build.model_alias,
|
|
1349
|
+
model=model,
|
|
1350
|
+
effort=build.requested_effort,
|
|
1351
|
+
detail="reasoning effort is not supported",
|
|
1352
|
+
),
|
|
1353
|
+
)
|
|
1354
|
+
kimi = build.config["kimi"]
|
|
1355
|
+
if isinstance(build.model_alias, str) and build.model_alias:
|
|
1356
|
+
model = build.model_alias
|
|
1357
|
+
else:
|
|
1358
|
+
model = _resolve_default_model(kimi)
|
|
1359
|
+
argv = build_kimi_argv(
|
|
1360
|
+
kimi,
|
|
1361
|
+
build.mode,
|
|
1362
|
+
build.resolved.path,
|
|
1363
|
+
model,
|
|
1364
|
+
build.prompt,
|
|
1365
|
+
stream_capture=build.stream_capture,
|
|
1366
|
+
)
|
|
1367
|
+
return EngineRequestParts(
|
|
1368
|
+
model=model,
|
|
1369
|
+
argv=argv,
|
|
1370
|
+
model_alias=build.model_alias,
|
|
1371
|
+
prompt_transport=PROMPT_TRANSPORT_ARGV,
|
|
1372
|
+
display_argv=redacted_prompt_argv(argv, replacement=KIMI_PROMPT_REDACTION),
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
EngineRequestPartsBuilder = Callable[[EngineBuildInput], EngineRequestParts]
|
|
1377
|
+
|
|
1378
|
+
ENGINE_REQUEST_PARTS_BUILDERS: dict[str, EngineRequestPartsBuilder] = {
|
|
1379
|
+
"cursor": _cursor_request_parts,
|
|
1380
|
+
"droid": _droid_request_parts,
|
|
1381
|
+
"codex": _codex_request_parts,
|
|
1382
|
+
"claude": _claude_request_parts,
|
|
1383
|
+
"grok": _grok_request_parts,
|
|
1384
|
+
"kimi": _kimi_request_parts,
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _engine_request_parts(
|
|
1389
|
+
engine: str,
|
|
1390
|
+
*,
|
|
1391
|
+
build: EngineBuildInput,
|
|
1392
|
+
) -> EngineRequestParts:
|
|
1393
|
+
builder = ENGINE_REQUEST_PARTS_BUILDERS.get(engine)
|
|
1394
|
+
if builder is None:
|
|
1395
|
+
raise DelegateError(
|
|
1396
|
+
"invalid_engine",
|
|
1397
|
+
f"engine must be {ENGINES_PROSE}.",
|
|
1398
|
+
)
|
|
1399
|
+
return builder(build)
|
|
1400
|
+
|
|
1401
|
+
|
|
1402
|
+
def _build_request_for_workspace(
|
|
1403
|
+
engine: str,
|
|
1404
|
+
mode: str,
|
|
1405
|
+
model_alias: str | None,
|
|
1406
|
+
resolved: ResolvedWorkspace,
|
|
1407
|
+
prompt: str,
|
|
1408
|
+
config: JsonObject,
|
|
1409
|
+
dry_run: bool,
|
|
1410
|
+
*,
|
|
1411
|
+
stream_capture: bool,
|
|
1412
|
+
isolation_context: IsolationContext | None,
|
|
1413
|
+
requested_effort: str | None,
|
|
1414
|
+
effort_source: str | None,
|
|
1415
|
+
progress: bool,
|
|
1416
|
+
progress_initial_delay_sec: float,
|
|
1417
|
+
progress_interval_sec: float,
|
|
1418
|
+
forbid_commit: bool,
|
|
1419
|
+
include_dirty: bool,
|
|
1420
|
+
auth_profile_override: str | None,
|
|
1421
|
+
output_schema: str | None,
|
|
1422
|
+
warnings: tuple[str, ...],
|
|
1423
|
+
cleanup_workspace: bool,
|
|
1424
|
+
call_read_only: bool = False,
|
|
1425
|
+
group: str | None = None,
|
|
1426
|
+
) -> Request:
|
|
1427
|
+
prompt = _append_safe_dirty_tree_note(prompt, resolved, mode, isolation_context)
|
|
1428
|
+
workspace_warning = wsl.drivefs_workspace_warning(resolved.path)
|
|
1429
|
+
if workspace_warning is not None:
|
|
1430
|
+
warnings = (*warnings, workspace_warning)
|
|
1431
|
+
cache = (
|
|
1432
|
+
reasoning.load_reasoning_capability_cache(resolved.path)
|
|
1433
|
+
if requested_effort is not None
|
|
1434
|
+
else None
|
|
1435
|
+
)
|
|
1436
|
+
parts = _engine_request_parts(
|
|
1437
|
+
engine,
|
|
1438
|
+
build=EngineBuildInput(
|
|
1439
|
+
mode=mode,
|
|
1440
|
+
model_alias=model_alias,
|
|
1441
|
+
resolved=resolved,
|
|
1442
|
+
prompt=prompt,
|
|
1443
|
+
config=config,
|
|
1444
|
+
stream_capture=stream_capture,
|
|
1445
|
+
requested_effort=requested_effort,
|
|
1446
|
+
effort_source=effort_source,
|
|
1447
|
+
cache=cache,
|
|
1448
|
+
output_schema=output_schema,
|
|
1449
|
+
call_read_only=call_read_only,
|
|
1450
|
+
),
|
|
1451
|
+
)
|
|
1452
|
+
return _apply_profile_resolution(
|
|
1453
|
+
Request(
|
|
1454
|
+
engine,
|
|
1455
|
+
mode,
|
|
1456
|
+
resolved.path,
|
|
1457
|
+
prompt,
|
|
1458
|
+
parts.argv,
|
|
1459
|
+
parts.model,
|
|
1460
|
+
model_alias=parts.model_alias,
|
|
1461
|
+
output_schema=output_schema,
|
|
1462
|
+
dry_run=dry_run,
|
|
1463
|
+
workspace_kind=resolved.kind,
|
|
1464
|
+
isolation_context=isolation_context,
|
|
1465
|
+
reasoning_effort=parts.reasoning_effort,
|
|
1466
|
+
reasoning_effort_source=parts.reasoning_effort_source,
|
|
1467
|
+
reasoning_capability_source=parts.reasoning_capability_source,
|
|
1468
|
+
reasoning_transport=parts.reasoning_transport,
|
|
1469
|
+
progress=progress,
|
|
1470
|
+
progress_initial_delay_sec=progress_initial_delay_sec,
|
|
1471
|
+
progress_interval_sec=progress_interval_sec,
|
|
1472
|
+
forbid_commit=forbid_commit,
|
|
1473
|
+
include_dirty=include_dirty,
|
|
1474
|
+
warnings=(*warnings, *parts.warnings),
|
|
1475
|
+
stdin_text=parts.stdin_text,
|
|
1476
|
+
prompt_file_text=parts.prompt_file_text,
|
|
1477
|
+
prompt_transport=parts.prompt_transport,
|
|
1478
|
+
display_argv=parts.display_argv,
|
|
1479
|
+
cleanup_workspace=cleanup_workspace,
|
|
1480
|
+
group=group,
|
|
1481
|
+
),
|
|
1482
|
+
config,
|
|
1483
|
+
auth_profile_override=auth_profile_override,
|
|
1484
|
+
)
|
|
1485
|
+
|
|
1486
|
+
|
|
1487
|
+
def _dedupe_warnings(warnings: tuple[str, ...]) -> tuple[str, ...]:
|
|
1488
|
+
seen: set[str] = set()
|
|
1489
|
+
deduped: list[str] = []
|
|
1490
|
+
for warning in warnings:
|
|
1491
|
+
if warning in seen:
|
|
1492
|
+
continue
|
|
1493
|
+
seen.add(warning)
|
|
1494
|
+
deduped.append(warning)
|
|
1495
|
+
return tuple(deduped)
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def _apply_profile_resolution(
|
|
1499
|
+
request: Request, config: JsonObject, *, auth_profile_override: str | None = None
|
|
1500
|
+
) -> Request:
|
|
1501
|
+
resolution = profiles.resolve_active_profile(
|
|
1502
|
+
config, os.environ, cli_override=auth_profile_override
|
|
1503
|
+
)
|
|
1504
|
+
env_overrides = dict(request.env_overrides or {})
|
|
1505
|
+
env_overrides.update(resolution.env)
|
|
1506
|
+
auth_profile = resolution.name
|
|
1507
|
+
fallback_profile = None
|
|
1508
|
+
if request.engine == "codex":
|
|
1509
|
+
if resolution.name is not None and resolution.codex_home is None:
|
|
1510
|
+
raise DelegateError(
|
|
1511
|
+
"profile_missing_codex_home",
|
|
1512
|
+
f"Profile {resolution.name!r} is active for a Codex Run but does not define CODEX_HOME.",
|
|
1513
|
+
)
|
|
1514
|
+
if resolution.name is not None:
|
|
1515
|
+
fallback_profile = profiles.codex_fallback_profile(config)
|
|
1516
|
+
return replace(
|
|
1517
|
+
request,
|
|
1518
|
+
warnings=_dedupe_warnings((*request.warnings, *resolution.warnings)),
|
|
1519
|
+
env_overrides=env_overrides or None,
|
|
1520
|
+
auth_profile=auth_profile,
|
|
1521
|
+
fallback_auth_profile=fallback_profile,
|
|
1522
|
+
profile_resolution=resolution,
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def resolve_effective_reasoning_effort(
|
|
1527
|
+
config: JsonObject,
|
|
1528
|
+
engine: str,
|
|
1529
|
+
requested: str | None,
|
|
1530
|
+
*,
|
|
1531
|
+
reasoning_effort_source: str | None,
|
|
1532
|
+
) -> tuple[str | None, str | None]:
|
|
1533
|
+
if requested is not None:
|
|
1534
|
+
try:
|
|
1535
|
+
return reasoning.normalize_effort(requested), reasoning_effort_source or "cli"
|
|
1536
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
1537
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
1538
|
+
section = config.get(engine)
|
|
1539
|
+
if isinstance(section, dict):
|
|
1540
|
+
default = section.get("defaultReasoningEffort")
|
|
1541
|
+
if default is not None:
|
|
1542
|
+
try:
|
|
1543
|
+
return reasoning.normalize_effort(default), "config"
|
|
1544
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
1545
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
1546
|
+
return None, None
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _capability_with_config_fallback(
|
|
1550
|
+
resolver: Callable[[], reasoning.ReasoningCapability | None],
|
|
1551
|
+
*,
|
|
1552
|
+
engine: str,
|
|
1553
|
+
effort_source: str | None,
|
|
1554
|
+
) -> tuple[reasoning.ReasoningCapability | None, tuple[str, ...]]:
|
|
1555
|
+
"""Resolve a capability, degrading config-default failures to a warning.
|
|
1556
|
+
|
|
1557
|
+
An explicit per-run --reasoning-effort fails closed, but a config
|
|
1558
|
+
defaultReasoningEffort is a preference: when it cannot be satisfied (no
|
|
1559
|
+
resolved model, no capability declaration, no cursor mapping), the run
|
|
1560
|
+
proceeds without reasoning effort instead of bricking every launch of the
|
|
1561
|
+
engine until the config is edited.
|
|
1562
|
+
"""
|
|
1563
|
+
try:
|
|
1564
|
+
return resolver(), ()
|
|
1565
|
+
except reasoning.ReasoningCapabilityError as exc:
|
|
1566
|
+
if effort_source != "config":
|
|
1567
|
+
raise DelegateError(exc.error, exc.message) from exc
|
|
1568
|
+
return None, (f"ignoring {engine}.defaultReasoningEffort: {exc.message}",)
|
|
1569
|
+
except DelegateError as exc:
|
|
1570
|
+
if effort_source != "config":
|
|
1571
|
+
raise
|
|
1572
|
+
return None, (f"ignoring {engine}.defaultReasoningEffort: {exc.message}",)
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def resolve_cursor_reasoning_capability(
|
|
1576
|
+
cursor_config: JsonObject,
|
|
1577
|
+
requested_effort: str | None,
|
|
1578
|
+
) -> reasoning.ReasoningCapability | None:
|
|
1579
|
+
if requested_effort is None:
|
|
1580
|
+
return None
|
|
1581
|
+
default_model = cursor_config.get("defaultModel")
|
|
1582
|
+
alias = default_model if isinstance(default_model, str) and default_model else None
|
|
1583
|
+
mappings = cursor_config.get("reasoningEffortModels")
|
|
1584
|
+
if not isinstance(mappings, dict):
|
|
1585
|
+
raise DelegateError(
|
|
1586
|
+
"unsupported_reasoning_effort",
|
|
1587
|
+
reasoning.format_explicit_reasoning_effort_error(
|
|
1588
|
+
harness="cursor",
|
|
1589
|
+
alias=alias,
|
|
1590
|
+
model=alias,
|
|
1591
|
+
effort=requested_effort,
|
|
1592
|
+
detail="requires cursor.reasoningEffortModels",
|
|
1593
|
+
),
|
|
1594
|
+
)
|
|
1595
|
+
model = mappings.get(requested_effort)
|
|
1596
|
+
if not isinstance(model, str) or not model:
|
|
1597
|
+
supported = sorted(
|
|
1598
|
+
effort
|
|
1599
|
+
for effort, mapped_model in mappings.items()
|
|
1600
|
+
if isinstance(effort, str) and effort and isinstance(mapped_model, str) and mapped_model
|
|
1601
|
+
)
|
|
1602
|
+
raise DelegateError(
|
|
1603
|
+
"unsupported_reasoning_effort",
|
|
1604
|
+
reasoning.format_explicit_reasoning_effort_error(
|
|
1605
|
+
harness="cursor",
|
|
1606
|
+
alias=alias,
|
|
1607
|
+
model=alias,
|
|
1608
|
+
effort=requested_effort,
|
|
1609
|
+
supported=supported or None,
|
|
1610
|
+
detail="requires a configured model mapping",
|
|
1611
|
+
),
|
|
1612
|
+
)
|
|
1613
|
+
return reasoning.ReasoningCapability(
|
|
1614
|
+
harness="cursor",
|
|
1615
|
+
model=model,
|
|
1616
|
+
effort=requested_effort,
|
|
1617
|
+
supported_efforts=(requested_effort,),
|
|
1618
|
+
default_effort=None,
|
|
1619
|
+
transport=reasoning.TRANSPORT_BY_HARNESS["cursor"],
|
|
1620
|
+
source="config",
|
|
1621
|
+
)
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
def reasoning_request_kwargs(
|
|
1625
|
+
capability: reasoning.ReasoningCapability | None,
|
|
1626
|
+
source: str | None,
|
|
1627
|
+
) -> JsonObject:
|
|
1628
|
+
if capability is None:
|
|
1629
|
+
return {}
|
|
1630
|
+
return {
|
|
1631
|
+
"reasoning_effort": capability.effort,
|
|
1632
|
+
"reasoning_effort_source": source or "cli",
|
|
1633
|
+
"reasoning_capability_source": capability.source,
|
|
1634
|
+
"reasoning_transport": capability.transport,
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
|
|
1638
|
+
def _native_reasoning_request_kwargs(
|
|
1639
|
+
effort: str | None,
|
|
1640
|
+
source: str | None,
|
|
1641
|
+
*,
|
|
1642
|
+
transport: str,
|
|
1643
|
+
capability_source: str,
|
|
1644
|
+
) -> JsonObject:
|
|
1645
|
+
if effort is None:
|
|
1646
|
+
return {}
|
|
1647
|
+
return {
|
|
1648
|
+
"reasoning_effort": effort,
|
|
1649
|
+
"reasoning_effort_source": source,
|
|
1650
|
+
"reasoning_capability_source": capability_source,
|
|
1651
|
+
"reasoning_transport": transport,
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def claude_reasoning_request_kwargs(effort: str | None, source: str | None) -> JsonObject:
|
|
1656
|
+
"""Reasoning payload fields for Claude's static native-effort flag.
|
|
1657
|
+
|
|
1658
|
+
The sibling of ``reasoning_request_kwargs`` for the engine that resolves a
|
|
1659
|
+
flag (``--effort``) rather than a ``ReasoningCapability`` object. Returns
|
|
1660
|
+
``{}`` when no effort applies so the request-parts defaults stand.
|
|
1661
|
+
"""
|
|
1662
|
+
return _native_reasoning_request_kwargs(
|
|
1663
|
+
effort,
|
|
1664
|
+
source,
|
|
1665
|
+
transport=reasoning.TRANSPORT_CLAUDE_EFFORT_FLAG,
|
|
1666
|
+
capability_source="static",
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def grok_reasoning_request_kwargs(effort: str | None, source: str | None) -> JsonObject:
|
|
1671
|
+
return _native_reasoning_request_kwargs(
|
|
1672
|
+
effort,
|
|
1673
|
+
source,
|
|
1674
|
+
transport=reasoning.TRANSPORT_GROK_EFFORT_FLAG,
|
|
1675
|
+
capability_source="static",
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def _resolve_default_model(section: JsonObject) -> str | None:
|
|
1680
|
+
default_model = section.get("defaultModel")
|
|
1681
|
+
return default_model if isinstance(default_model, str) and default_model else None
|