aicortexpy 0.1.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.
- aicortexpy/__init__.py +3 -0
- aicortexpy/__main__.py +5 -0
- aicortexpy/acp_client.py +559 -0
- aicortexpy/acp_conn.py +853 -0
- aicortexpy/acp_errors.py +107 -0
- aicortexpy/agent_env.py +88 -0
- aicortexpy/artifact_ws.py +233 -0
- aicortexpy/artifacts.py +67 -0
- aicortexpy/attachments.py +85 -0
- aicortexpy/auto_commit.py +106 -0
- aicortexpy/capabilities.py +92 -0
- aicortexpy/chat_acp_pool.py +321 -0
- aicortexpy/cli.py +133 -0
- aicortexpy/client.py +438 -0
- aicortexpy/codex_config.py +98 -0
- aicortexpy/config.py +298 -0
- aicortexpy/daemon.py +677 -0
- aicortexpy/data/default_skills/agent-memory/SKILL.md +67 -0
- aicortexpy/data/default_skills/aicorkit-atlassian/SKILL.md +144 -0
- aicortexpy/data/default_skills/aicorkit-graph/SKILL.md +145 -0
- aicortexpy/data/default_skills/aicorkit-outlook/SKILL.md +87 -0
- aicortexpy/data/default_skills/aicorkit-wxmsg/SKILL.md +96 -0
- aicortexpy/data/default_skills/aicortex-basics/SKILL.md +10 -0
- aicortexpy/data/default_skills/aicortex-bridge-cdp/SKILL.md +118 -0
- aicortexpy/data/default_skills/aicortex-cli-ops/SKILL.md +100 -0
- aicortexpy/data/default_skills/aicortex-graph/SKILL.md +30 -0
- aicortexpy/data/default_skills/graphify/SKILL.md +97 -0
- aicortexpy/data/default_skills/question-form/SKILL.md +162 -0
- aicortexpy/data/default_skills/test-report-generator/SKILL.md +179 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_cases.py +146 -0
- aicortexpy/data/default_skills/test-report-generator/scripts/generate_report.py +275 -0
- aicortexpy/design_seed.py +59 -0
- aicortexpy/diskusage.py +209 -0
- aicortexpy/execenv.py +264 -0
- aicortexpy/gc.py +286 -0
- aicortexpy/handoff_bundle.py +65 -0
- aicortexpy/health.py +178 -0
- aicortexpy/local_skills.py +157 -0
- aicortexpy/models.py +385 -0
- aicortexpy/openclaw_config.py +153 -0
- aicortexpy/pinned_workdir.py +60 -0
- aicortexpy/platform_info.py +61 -0
- aicortexpy/poisoned.py +44 -0
- aicortexpy/process.py +168 -0
- aicortexpy/procutil.py +61 -0
- aicortexpy/prompt.py +235 -0
- aicortexpy/repo.py +462 -0
- aicortexpy/runner.py +969 -0
- aicortexpy/runtime_config.py +540 -0
- aicortexpy/sandbox.py +264 -0
- aicortexpy/stream.py +422 -0
- aicortexpy/terminal.py +147 -0
- aicortexpy/tunnel.py +82 -0
- aicortexpy/wakeup.py +222 -0
- aicortexpy-0.1.0.dist-info/METADATA +106 -0
- aicortexpy-0.1.0.dist-info/RECORD +58 -0
- aicortexpy-0.1.0.dist-info/WHEEL +5 -0
- aicortexpy-0.1.0.dist-info/top_level.txt +1 -0
aicortexpy/runner.py
ADDED
|
@@ -0,0 +1,969 @@
|
|
|
1
|
+
"""Agent launcher: execenv, cancel, stream, pin, repos."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from .acp_conn import (
|
|
20
|
+
ACP_ONESHOT_PROVIDERS,
|
|
21
|
+
ACPPromptResult,
|
|
22
|
+
choose_cursor_invocation,
|
|
23
|
+
run_acp_oneshot,
|
|
24
|
+
)
|
|
25
|
+
from .agent_env import build_agent_environ
|
|
26
|
+
from .attachments import materialize_attachments
|
|
27
|
+
from .chat_acp_pool import (
|
|
28
|
+
ChatACPPool,
|
|
29
|
+
agent_id_from_task,
|
|
30
|
+
pool_key,
|
|
31
|
+
skip_cursor_chat_session_resume,
|
|
32
|
+
)
|
|
33
|
+
from .client import DaemonClient, Task
|
|
34
|
+
from .config import AgentEntry, Config
|
|
35
|
+
from .execenv import Environment, prepare_environment, provider_needs_inline_system_prompt
|
|
36
|
+
from .procutil import kill_process_tree, popen_kwargs_for_killable_group
|
|
37
|
+
from .repo import ensure_repos_for_task
|
|
38
|
+
from .sandbox import apply_plan_to_command, cleanup_plan, prepare_provider_sandbox
|
|
39
|
+
from .stream import STREAM_JSON_PROVIDERS, StreamCollector
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger("aicortexpy.runner")
|
|
42
|
+
|
|
43
|
+
MessageFlush = Callable[[list[dict]], None]
|
|
44
|
+
SessionPin = Callable[[str, str], None] # session_id, work_dir
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RunResult:
|
|
49
|
+
ok: bool
|
|
50
|
+
output: str
|
|
51
|
+
work_dir: str
|
|
52
|
+
error: str = ""
|
|
53
|
+
sandbox_label: str = ""
|
|
54
|
+
cancelled: bool = False
|
|
55
|
+
session_id: str = ""
|
|
56
|
+
messages: list[dict] = field(default_factory=list)
|
|
57
|
+
usage: list[dict] = field(default_factory=list)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _custom_args(agent: dict | None) -> list[str]:
|
|
61
|
+
if not agent:
|
|
62
|
+
return []
|
|
63
|
+
args = agent.get("custom_args")
|
|
64
|
+
if isinstance(args, list):
|
|
65
|
+
return [str(a) for a in args]
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolve_model(entry: AgentEntry, task: Task) -> str:
|
|
70
|
+
if entry.model:
|
|
71
|
+
return entry.model
|
|
72
|
+
agent = task.agent or {}
|
|
73
|
+
return str(agent.get("model") or "")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _custom_args_has(extra: list[str], flag: str) -> bool:
|
|
77
|
+
prefix = flag + "="
|
|
78
|
+
return any(a == flag or a.startswith(prefix) for a in extra)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pi_session_path(task: Task) -> str:
|
|
82
|
+
resume = (task.prior_session_id or "").strip()
|
|
83
|
+
if resume:
|
|
84
|
+
path = Path(resume)
|
|
85
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
if not path.exists():
|
|
87
|
+
path.touch()
|
|
88
|
+
return str(path)
|
|
89
|
+
base = Path.home() / ".aicortex" / "pi-sessions"
|
|
90
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
name = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%f") + ".jsonl"
|
|
92
|
+
path = base / name
|
|
93
|
+
path.touch()
|
|
94
|
+
return str(path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _split_pi_model(model: str) -> tuple[str, str]:
|
|
98
|
+
model = (model or "").strip()
|
|
99
|
+
if "/" in model:
|
|
100
|
+
provider, mid = model.split("/", 1)
|
|
101
|
+
return provider.strip(), mid.strip()
|
|
102
|
+
return "", model
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _build_command(
|
|
106
|
+
provider: str,
|
|
107
|
+
entry: AgentEntry,
|
|
108
|
+
prompt: str,
|
|
109
|
+
task: Task,
|
|
110
|
+
*,
|
|
111
|
+
work_dir: Path | None = None,
|
|
112
|
+
system_prompt: str = "",
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
path = entry.path
|
|
115
|
+
agent = task.agent
|
|
116
|
+
extra = _custom_args(agent)
|
|
117
|
+
model = _resolve_model(entry, task)
|
|
118
|
+
|
|
119
|
+
if provider == "claude":
|
|
120
|
+
cmd = [
|
|
121
|
+
path,
|
|
122
|
+
"-p",
|
|
123
|
+
prompt,
|
|
124
|
+
"--output-format",
|
|
125
|
+
"stream-json",
|
|
126
|
+
"--verbose",
|
|
127
|
+
"--permission-mode",
|
|
128
|
+
"bypassPermissions",
|
|
129
|
+
"--strict-mcp-config",
|
|
130
|
+
]
|
|
131
|
+
if model:
|
|
132
|
+
cmd.extend(["--model", model])
|
|
133
|
+
if task.prior_session_id:
|
|
134
|
+
cmd.extend(["--resume", task.prior_session_id])
|
|
135
|
+
# Filter blocked custom args that daemon owns.
|
|
136
|
+
filtered = _filter_blocked_args(
|
|
137
|
+
extra, {"--mcp-config", "--output-format", "--permission-mode", "--strict-mcp-config"}
|
|
138
|
+
)
|
|
139
|
+
cmd.extend(filtered)
|
|
140
|
+
return cmd
|
|
141
|
+
|
|
142
|
+
if provider == "codex":
|
|
143
|
+
cmd = [path, "exec", "--skip-git-repo-check", prompt]
|
|
144
|
+
if model:
|
|
145
|
+
cmd.extend(["-m", model])
|
|
146
|
+
cmd.extend(extra)
|
|
147
|
+
return cmd
|
|
148
|
+
|
|
149
|
+
if provider == "gemini":
|
|
150
|
+
cmd = [path, "-p", prompt, "--yolo", "-o", "stream-json"]
|
|
151
|
+
if model:
|
|
152
|
+
cmd.extend(["-m", model])
|
|
153
|
+
if task.prior_session_id:
|
|
154
|
+
cmd.extend(["-r", task.prior_session_id])
|
|
155
|
+
cmd.extend(extra)
|
|
156
|
+
return cmd
|
|
157
|
+
|
|
158
|
+
if provider == "cursor":
|
|
159
|
+
cmd = [path, "-p", prompt, "--output-format", "stream-json", "--yolo"]
|
|
160
|
+
if work_dir is not None:
|
|
161
|
+
cmd.extend(["--workspace", str(work_dir)])
|
|
162
|
+
if model:
|
|
163
|
+
cmd.extend(["--model", model])
|
|
164
|
+
if task.prior_session_id and not task.chat_session_id:
|
|
165
|
+
cmd.extend(["--resume", task.prior_session_id])
|
|
166
|
+
cmd.extend(extra)
|
|
167
|
+
return cmd
|
|
168
|
+
|
|
169
|
+
if provider == "openclaw":
|
|
170
|
+
session_id = task.prior_session_id or f"aicortex-{uuid.uuid4().hex}"
|
|
171
|
+
msg = prompt
|
|
172
|
+
if system_prompt:
|
|
173
|
+
msg = system_prompt + "\n\n" + prompt
|
|
174
|
+
cmd = [path, "agent", "--local", "--json", "--session-id", session_id]
|
|
175
|
+
if model and not _custom_args_has(extra, "--agent"):
|
|
176
|
+
cmd.extend(["--agent", model])
|
|
177
|
+
cmd.extend(extra)
|
|
178
|
+
cmd.extend(["--message", msg])
|
|
179
|
+
return cmd
|
|
180
|
+
|
|
181
|
+
if provider == "pi":
|
|
182
|
+
session_path = _pi_session_path(task)
|
|
183
|
+
cmd = [path, "-p", "--mode", "json", "--session", session_path]
|
|
184
|
+
if model:
|
|
185
|
+
pi_provider, pi_model = _split_pi_model(model)
|
|
186
|
+
if pi_provider:
|
|
187
|
+
cmd.extend(["--provider", pi_provider])
|
|
188
|
+
if pi_model:
|
|
189
|
+
cmd.extend(["--model", pi_model])
|
|
190
|
+
if system_prompt:
|
|
191
|
+
cmd.extend(["--append-system-prompt", system_prompt])
|
|
192
|
+
cmd.extend(extra)
|
|
193
|
+
cmd.append(prompt)
|
|
194
|
+
return cmd
|
|
195
|
+
|
|
196
|
+
if provider == "opencode":
|
|
197
|
+
cmd = [path, "run", "--format", "json"]
|
|
198
|
+
if model:
|
|
199
|
+
cmd.extend(["--model", model])
|
|
200
|
+
if system_prompt:
|
|
201
|
+
cmd.extend(["--prompt", system_prompt])
|
|
202
|
+
if task.prior_session_id:
|
|
203
|
+
cmd.extend(["--session", task.prior_session_id])
|
|
204
|
+
cmd.extend(_filter_blocked_args(extra, {"--format"}))
|
|
205
|
+
cmd.append(prompt)
|
|
206
|
+
return cmd
|
|
207
|
+
|
|
208
|
+
if provider == "copilot":
|
|
209
|
+
cmd = [
|
|
210
|
+
path,
|
|
211
|
+
"-p",
|
|
212
|
+
prompt,
|
|
213
|
+
"--output-format",
|
|
214
|
+
"json",
|
|
215
|
+
"--allow-all",
|
|
216
|
+
"--no-ask-user",
|
|
217
|
+
]
|
|
218
|
+
if model:
|
|
219
|
+
cmd.extend(["--model", model])
|
|
220
|
+
if task.prior_session_id:
|
|
221
|
+
cmd.extend(["--resume", task.prior_session_id])
|
|
222
|
+
cmd.extend(
|
|
223
|
+
_filter_blocked_args(
|
|
224
|
+
extra,
|
|
225
|
+
{
|
|
226
|
+
"-p",
|
|
227
|
+
"--output-format",
|
|
228
|
+
"--allow-all",
|
|
229
|
+
"--yolo",
|
|
230
|
+
"--no-ask-user",
|
|
231
|
+
"--resume",
|
|
232
|
+
"--acp",
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return cmd
|
|
237
|
+
|
|
238
|
+
return [path, prompt, *extra]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _filter_blocked_args(args: list[str], blocked: set[str]) -> list[str]:
|
|
242
|
+
"""Drop daemon-owned flags (and their values) from custom_args."""
|
|
243
|
+
out: list[str] = []
|
|
244
|
+
skip_next = False
|
|
245
|
+
for a in args:
|
|
246
|
+
if skip_next:
|
|
247
|
+
skip_next = False
|
|
248
|
+
continue
|
|
249
|
+
name = a.split("=", 1)[0]
|
|
250
|
+
if name in blocked or a in blocked:
|
|
251
|
+
if "=" not in a and name in blocked:
|
|
252
|
+
skip_next = True
|
|
253
|
+
continue
|
|
254
|
+
out.append(a)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _hide_window_kwargs() -> dict:
|
|
259
|
+
if sys.platform != "win32":
|
|
260
|
+
return {}
|
|
261
|
+
startup = subprocess.STARTUPINFO()
|
|
262
|
+
startup.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
263
|
+
return {"startupinfo": startup}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cleanup_mcp_tmp(path: str | None) -> None:
|
|
267
|
+
if not path:
|
|
268
|
+
return
|
|
269
|
+
try:
|
|
270
|
+
os.unlink(path)
|
|
271
|
+
except OSError:
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_agent(
|
|
276
|
+
task: Task,
|
|
277
|
+
provider: str,
|
|
278
|
+
entry: AgentEntry,
|
|
279
|
+
workspaces_root: Path,
|
|
280
|
+
timeout: float,
|
|
281
|
+
*,
|
|
282
|
+
client: DaemonClient | None = None,
|
|
283
|
+
cfg: Config | None = None,
|
|
284
|
+
task_slot: int = 0,
|
|
285
|
+
cancel_event: threading.Event | None = None,
|
|
286
|
+
on_messages: MessageFlush | None = None,
|
|
287
|
+
on_session: SessionPin | None = None,
|
|
288
|
+
acp_pool: ChatACPPool | None = None,
|
|
289
|
+
idle_watchdog: float = 0,
|
|
290
|
+
) -> RunResult:
|
|
291
|
+
env_info: Environment = prepare_environment(workspaces_root, task, provider)
|
|
292
|
+
work_dir = env_info.work_dir
|
|
293
|
+
|
|
294
|
+
# Design→dev handoff zip (Priority 1b in Go runTask).
|
|
295
|
+
if task.handoff_bundle_url:
|
|
296
|
+
try:
|
|
297
|
+
from .handoff_bundle import materialize_handoff_bundle
|
|
298
|
+
|
|
299
|
+
materialize_handoff_bundle(
|
|
300
|
+
work_dir,
|
|
301
|
+
task.handoff_bundle_url,
|
|
302
|
+
token=(cfg.token if cfg else ""),
|
|
303
|
+
server_base=(cfg.server_base_url if cfg else ""),
|
|
304
|
+
)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
return RunResult(
|
|
307
|
+
ok=False,
|
|
308
|
+
output="",
|
|
309
|
+
work_dir=str(work_dir),
|
|
310
|
+
error=f"handoff bundle: {e}",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if task.design_example_id:
|
|
314
|
+
try:
|
|
315
|
+
from .design_seed import seed_example_workdir
|
|
316
|
+
|
|
317
|
+
seed_example_workdir(
|
|
318
|
+
work_dir,
|
|
319
|
+
task.design_example_id,
|
|
320
|
+
task.design_example_bundles,
|
|
321
|
+
design_studio=bool(task.design_mode),
|
|
322
|
+
)
|
|
323
|
+
except Exception as e:
|
|
324
|
+
log.warning("design seed: %s", e)
|
|
325
|
+
|
|
326
|
+
if client is not None:
|
|
327
|
+
try:
|
|
328
|
+
materialize_attachments(client, task, work_dir)
|
|
329
|
+
# refresh prompt if attachments arrived for design/chat
|
|
330
|
+
if task.design_incoming_rel_paths:
|
|
331
|
+
from .prompt import build_prompt
|
|
332
|
+
|
|
333
|
+
(work_dir / "PROMPT.md").write_text(
|
|
334
|
+
build_prompt(task, provider), encoding="utf-8"
|
|
335
|
+
)
|
|
336
|
+
except Exception as e:
|
|
337
|
+
log.warning("attachments: %s", e)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
ensure_repos_for_task(workspaces_root, task, work_dir)
|
|
341
|
+
except Exception as e:
|
|
342
|
+
log.warning("repo prepare: %s", e)
|
|
343
|
+
|
|
344
|
+
prompt = (work_dir / "PROMPT.md").read_text(encoding="utf-8")
|
|
345
|
+
system_prompt = ""
|
|
346
|
+
if provider_needs_inline_system_prompt(provider):
|
|
347
|
+
system_prompt = env_info.runtime_brief or ""
|
|
348
|
+
plan = prepare_provider_sandbox(provider, work_dir)
|
|
349
|
+
if env_info.codex_home:
|
|
350
|
+
plan.env_updates["CODEX_HOME"] = str(env_info.codex_home)
|
|
351
|
+
if env_info.env_updates:
|
|
352
|
+
plan.env_updates.update(env_info.env_updates)
|
|
353
|
+
for n in plan.notes or []:
|
|
354
|
+
log.info("sandbox: %s", n)
|
|
355
|
+
|
|
356
|
+
if cfg is not None:
|
|
357
|
+
environ = build_agent_environ(
|
|
358
|
+
os.environ.copy(),
|
|
359
|
+
cfg=cfg,
|
|
360
|
+
task=task,
|
|
361
|
+
task_slot=task_slot,
|
|
362
|
+
extra=plan.env_updates,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
environ = os.environ.copy()
|
|
366
|
+
environ.update(plan.env_updates)
|
|
367
|
+
if provider == "gemini":
|
|
368
|
+
environ.setdefault("GEMINI_CLI_TRUST_WORKSPACE", "true")
|
|
369
|
+
if provider == "opencode":
|
|
370
|
+
environ.setdefault("OPENCODE_PERMISSION", '{"*":"allow"}')
|
|
371
|
+
|
|
372
|
+
mcp_tmp: str | None = None
|
|
373
|
+
agent = task.agent or {}
|
|
374
|
+
mcp_raw = agent.get("mcp_config")
|
|
375
|
+
if provider == "claude" and mcp_raw:
|
|
376
|
+
try:
|
|
377
|
+
if isinstance(mcp_raw, (dict, list)):
|
|
378
|
+
payload = json.dumps(mcp_raw, ensure_ascii=False)
|
|
379
|
+
else:
|
|
380
|
+
payload = str(mcp_raw)
|
|
381
|
+
fd, mcp_tmp = tempfile.mkstemp(prefix="aicortex-mcp-", suffix=".json")
|
|
382
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
383
|
+
fh.write(payload)
|
|
384
|
+
except OSError as e:
|
|
385
|
+
log.warning("mcp config tempfile failed: %s", e)
|
|
386
|
+
mcp_tmp = None
|
|
387
|
+
|
|
388
|
+
# Chat + Cursor/Kiro: prefer long-lived ACP pool; fall back to one-shot CLI.
|
|
389
|
+
if (
|
|
390
|
+
acp_pool is not None
|
|
391
|
+
and task.chat_session_id
|
|
392
|
+
and acp_pool.enabled_for(provider)
|
|
393
|
+
):
|
|
394
|
+
acp_result = _run_via_acp_pool(
|
|
395
|
+
task=task,
|
|
396
|
+
provider=provider,
|
|
397
|
+
entry=entry,
|
|
398
|
+
work_dir=work_dir,
|
|
399
|
+
prompt=prompt,
|
|
400
|
+
timeout=timeout,
|
|
401
|
+
environ=environ,
|
|
402
|
+
plan_label=plan.policy_label,
|
|
403
|
+
acp_pool=acp_pool,
|
|
404
|
+
cancel_event=cancel_event,
|
|
405
|
+
on_messages=on_messages,
|
|
406
|
+
on_session=on_session,
|
|
407
|
+
idle_watchdog=idle_watchdog,
|
|
408
|
+
system_prompt=system_prompt,
|
|
409
|
+
)
|
|
410
|
+
if acp_result is not None:
|
|
411
|
+
cleanup_plan(plan)
|
|
412
|
+
_cleanup_mcp_tmp(mcp_tmp)
|
|
413
|
+
return acp_result
|
|
414
|
+
log.warning("%s chat acp pool failed, falling back to one-shot backend", provider)
|
|
415
|
+
|
|
416
|
+
# Non-chat ACP providers (kiro/hermes/kimi/dsh) — always JSON-RPC, not argv prompt.
|
|
417
|
+
if provider in ACP_ONESHOT_PROVIDERS:
|
|
418
|
+
oneshot = _run_via_acp_oneshot(
|
|
419
|
+
task=task,
|
|
420
|
+
provider=provider,
|
|
421
|
+
entry=entry,
|
|
422
|
+
work_dir=work_dir,
|
|
423
|
+
prompt=prompt,
|
|
424
|
+
timeout=timeout,
|
|
425
|
+
environ=environ,
|
|
426
|
+
plan_label=plan.policy_label,
|
|
427
|
+
cancel_event=cancel_event,
|
|
428
|
+
on_messages=on_messages,
|
|
429
|
+
on_session=on_session,
|
|
430
|
+
idle_watchdog=idle_watchdog,
|
|
431
|
+
system_prompt=system_prompt,
|
|
432
|
+
)
|
|
433
|
+
cleanup_plan(plan)
|
|
434
|
+
_cleanup_mcp_tmp(mcp_tmp)
|
|
435
|
+
return oneshot
|
|
436
|
+
|
|
437
|
+
cmd = _build_command(
|
|
438
|
+
provider, entry, prompt, task, work_dir=work_dir, system_prompt=system_prompt
|
|
439
|
+
)
|
|
440
|
+
if mcp_tmp and provider == "claude":
|
|
441
|
+
cmd.extend(["--mcp-config", mcp_tmp])
|
|
442
|
+
if sys.platform == "win32" and sum(len(a) for a in cmd) > 7000:
|
|
443
|
+
short = (
|
|
444
|
+
f"Read and follow {work_dir / 'PROMPT.md'}. "
|
|
445
|
+
"Complete the AICortex task described there."
|
|
446
|
+
)
|
|
447
|
+
cmd = _build_command(
|
|
448
|
+
provider, entry, short, task, work_dir=work_dir, system_prompt=system_prompt
|
|
449
|
+
)
|
|
450
|
+
if provider == "cursor" and cmd:
|
|
451
|
+
argv0, args = choose_cursor_invocation(cmd[0], cmd[1:])
|
|
452
|
+
cmd = [argv0, *args]
|
|
453
|
+
cmd = apply_plan_to_command(plan, cmd)
|
|
454
|
+
|
|
455
|
+
log.info(
|
|
456
|
+
"spawn agent provider=%s sandbox=%s cwd=%s cmd0=%s",
|
|
457
|
+
provider,
|
|
458
|
+
plan.policy_label,
|
|
459
|
+
work_dir,
|
|
460
|
+
cmd[0],
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
collector = StreamCollector(provider=provider)
|
|
464
|
+
if provider == "pi":
|
|
465
|
+
for i, a in enumerate(cmd):
|
|
466
|
+
if a == "--session" and i + 1 < len(cmd):
|
|
467
|
+
collector.session_id = cmd[i + 1]
|
|
468
|
+
break
|
|
469
|
+
elif provider == "openclaw":
|
|
470
|
+
for i, a in enumerate(cmd):
|
|
471
|
+
if a == "--session-id" and i + 1 < len(cmd):
|
|
472
|
+
collector.session_id = cmd[i + 1]
|
|
473
|
+
break
|
|
474
|
+
pending_msgs: list[dict] = []
|
|
475
|
+
last_flush = time.time()
|
|
476
|
+
pinned_once = False
|
|
477
|
+
parse_stream = provider in STREAM_JSON_PROVIDERS
|
|
478
|
+
|
|
479
|
+
def flush_messages(force: bool = False) -> None:
|
|
480
|
+
nonlocal pending_msgs, last_flush
|
|
481
|
+
if not on_messages or not pending_msgs:
|
|
482
|
+
return
|
|
483
|
+
if not force and len(pending_msgs) < 5 and time.time() - last_flush < 2.0:
|
|
484
|
+
return
|
|
485
|
+
batch = pending_msgs
|
|
486
|
+
pending_msgs = []
|
|
487
|
+
last_flush = time.time()
|
|
488
|
+
try:
|
|
489
|
+
on_messages(batch)
|
|
490
|
+
except Exception as e:
|
|
491
|
+
log.warning("flush messages failed: %s", e)
|
|
492
|
+
|
|
493
|
+
def maybe_pin() -> None:
|
|
494
|
+
nonlocal pinned_once
|
|
495
|
+
if on_session and collector.session_id and not pinned_once:
|
|
496
|
+
pinned_once = True
|
|
497
|
+
try:
|
|
498
|
+
on_session(collector.session_id, str(work_dir))
|
|
499
|
+
except Exception as e:
|
|
500
|
+
log.warning("pin session failed: %s", e)
|
|
501
|
+
|
|
502
|
+
popen_kw = {
|
|
503
|
+
"cwd": str(work_dir),
|
|
504
|
+
"env": environ,
|
|
505
|
+
"stdout": subprocess.PIPE,
|
|
506
|
+
"stderr": subprocess.PIPE,
|
|
507
|
+
"text": True,
|
|
508
|
+
"bufsize": 1,
|
|
509
|
+
**popen_kwargs_for_killable_group(),
|
|
510
|
+
**_hide_window_kwargs(),
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
proc = subprocess.Popen(cmd, **popen_kw)
|
|
515
|
+
except FileNotFoundError:
|
|
516
|
+
cleanup_plan(plan)
|
|
517
|
+
_cleanup_mcp_tmp(mcp_tmp)
|
|
518
|
+
return RunResult(
|
|
519
|
+
ok=False,
|
|
520
|
+
output="",
|
|
521
|
+
work_dir=str(work_dir),
|
|
522
|
+
error=f"agent executable not found: {entry.path}",
|
|
523
|
+
sandbox_label=plan.policy_label,
|
|
524
|
+
)
|
|
525
|
+
except OSError as e:
|
|
526
|
+
cleanup_plan(plan)
|
|
527
|
+
_cleanup_mcp_tmp(mcp_tmp)
|
|
528
|
+
return RunResult(
|
|
529
|
+
ok=False,
|
|
530
|
+
output="",
|
|
531
|
+
work_dir=str(work_dir),
|
|
532
|
+
error=str(e),
|
|
533
|
+
sandbox_label=plan.policy_label,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Early pin of work_dir even without session
|
|
537
|
+
if on_session:
|
|
538
|
+
try:
|
|
539
|
+
on_session("", str(work_dir))
|
|
540
|
+
except Exception:
|
|
541
|
+
pass
|
|
542
|
+
|
|
543
|
+
stdout_chunks: list[str] = []
|
|
544
|
+
stderr_chunks: list[str] = []
|
|
545
|
+
cancelled = False
|
|
546
|
+
semantic_timeout = 0.0
|
|
547
|
+
if provider == "codex" and cfg is not None:
|
|
548
|
+
semantic_timeout = float(cfg.codex_semantic_inactivity or 0)
|
|
549
|
+
last_activity = time.time()
|
|
550
|
+
last_activity_desc = "spawn"
|
|
551
|
+
activity_lock = threading.Lock()
|
|
552
|
+
semantic_killed = False
|
|
553
|
+
|
|
554
|
+
def _touch_activity(desc: str) -> None:
|
|
555
|
+
nonlocal last_activity, last_activity_desc
|
|
556
|
+
with activity_lock:
|
|
557
|
+
last_activity = time.time()
|
|
558
|
+
last_activity_desc = desc
|
|
559
|
+
|
|
560
|
+
def _reader(stream, sink: list[str], parse: bool, label: str) -> None:
|
|
561
|
+
assert stream is not None
|
|
562
|
+
for line in stream:
|
|
563
|
+
sink.append(line)
|
|
564
|
+
_touch_activity(label)
|
|
565
|
+
if parse:
|
|
566
|
+
before = len(collector.messages)
|
|
567
|
+
collector.feed_line(line)
|
|
568
|
+
if len(collector.messages) > before:
|
|
569
|
+
pending_msgs.extend(collector.messages[before:])
|
|
570
|
+
flush_messages()
|
|
571
|
+
maybe_pin()
|
|
572
|
+
|
|
573
|
+
t_out = threading.Thread(
|
|
574
|
+
target=_reader,
|
|
575
|
+
args=(proc.stdout, stdout_chunks, parse_stream, "stdout"),
|
|
576
|
+
name="agent-stdout",
|
|
577
|
+
daemon=True,
|
|
578
|
+
)
|
|
579
|
+
t_err = threading.Thread(
|
|
580
|
+
target=_reader,
|
|
581
|
+
args=(proc.stderr, stderr_chunks, False, "stderr"),
|
|
582
|
+
name="agent-stderr",
|
|
583
|
+
daemon=True,
|
|
584
|
+
)
|
|
585
|
+
t_out.start()
|
|
586
|
+
t_err.start()
|
|
587
|
+
|
|
588
|
+
deadline = time.time() + timeout
|
|
589
|
+
while True:
|
|
590
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
591
|
+
cancelled = True
|
|
592
|
+
log.info("cancel requested; killing agent pid=%s", proc.pid)
|
|
593
|
+
kill_process_tree(proc.pid)
|
|
594
|
+
break
|
|
595
|
+
rc = proc.poll()
|
|
596
|
+
if rc is not None:
|
|
597
|
+
break
|
|
598
|
+
now = time.time()
|
|
599
|
+
if now >= deadline:
|
|
600
|
+
log.info("agent timeout; killing pid=%s", proc.pid)
|
|
601
|
+
kill_process_tree(proc.pid)
|
|
602
|
+
break
|
|
603
|
+
if semantic_timeout > 0:
|
|
604
|
+
with activity_lock:
|
|
605
|
+
idle = now - last_activity
|
|
606
|
+
idle_desc = last_activity_desc
|
|
607
|
+
if idle >= semantic_timeout:
|
|
608
|
+
semantic_killed = True
|
|
609
|
+
log.warning(
|
|
610
|
+
"codex semantic inactivity timeout after %.0fs (last=%s idle=%.1fs); killing pid=%s",
|
|
611
|
+
semantic_timeout,
|
|
612
|
+
idle_desc,
|
|
613
|
+
idle,
|
|
614
|
+
proc.pid,
|
|
615
|
+
)
|
|
616
|
+
kill_process_tree(proc.pid)
|
|
617
|
+
break
|
|
618
|
+
time.sleep(0.2)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
proc.wait(timeout=10)
|
|
622
|
+
except subprocess.TimeoutExpired:
|
|
623
|
+
kill_process_tree(proc.pid)
|
|
624
|
+
proc.wait(timeout=5)
|
|
625
|
+
|
|
626
|
+
t_out.join(timeout=5)
|
|
627
|
+
t_err.join(timeout=5)
|
|
628
|
+
cleanup_plan(plan)
|
|
629
|
+
_cleanup_mcp_tmp(mcp_tmp)
|
|
630
|
+
maybe_pin()
|
|
631
|
+
|
|
632
|
+
stdout = "".join(stdout_chunks)
|
|
633
|
+
stderr = "".join(stderr_chunks)
|
|
634
|
+
if not parse_stream:
|
|
635
|
+
collector.finalize_plain_output(stdout if stdout.strip() else stderr)
|
|
636
|
+
pending_msgs.extend(
|
|
637
|
+
m for m in collector.messages if m not in pending_msgs
|
|
638
|
+
)
|
|
639
|
+
flush_messages(force=True)
|
|
640
|
+
|
|
641
|
+
usage_list = [collector.usage.as_dict()] if collector.usage.any else []
|
|
642
|
+
output = collector.output_text() or (stdout if stdout.strip() else stderr)
|
|
643
|
+
|
|
644
|
+
common = dict(
|
|
645
|
+
work_dir=str(work_dir),
|
|
646
|
+
sandbox_label=plan.policy_label,
|
|
647
|
+
session_id=collector.session_id,
|
|
648
|
+
messages=list(collector.messages),
|
|
649
|
+
usage=usage_list,
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
if cancelled:
|
|
653
|
+
return RunResult(ok=False, output=output, error="cancelled", cancelled=True, **common)
|
|
654
|
+
|
|
655
|
+
if semantic_killed:
|
|
656
|
+
with activity_lock:
|
|
657
|
+
desc = last_activity_desc
|
|
658
|
+
return RunResult(
|
|
659
|
+
ok=False,
|
|
660
|
+
output=output,
|
|
661
|
+
error=(
|
|
662
|
+
f"codex semantic inactivity timeout after {semantic_timeout:.0f}s "
|
|
663
|
+
f"without agent progress (last activity: {desc})"
|
|
664
|
+
),
|
|
665
|
+
**common,
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
if proc.returncode is None:
|
|
669
|
+
return RunResult(
|
|
670
|
+
ok=False,
|
|
671
|
+
output=output,
|
|
672
|
+
error=f"agent timeout after {timeout:.0f}s",
|
|
673
|
+
**common,
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
if proc.returncode != 0:
|
|
677
|
+
err = f"agent exited {proc.returncode}"
|
|
678
|
+
if stderr.strip():
|
|
679
|
+
err = f"{err}: {stderr.strip()[:2000]}"
|
|
680
|
+
return RunResult(ok=False, output=output, error=err, **common)
|
|
681
|
+
|
|
682
|
+
return RunResult(ok=True, output=output or "(no output)", **common)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def _acp_message_flusher(on_messages: MessageFlush | None) -> tuple[list[dict], Any]:
|
|
686
|
+
"""Return (messages_acc, on_message_cb) that streams batches to the server."""
|
|
687
|
+
messages: list[dict] = []
|
|
688
|
+
pending: list[dict] = []
|
|
689
|
+
last_flush = time.time()
|
|
690
|
+
|
|
691
|
+
def flush(force: bool = False) -> None:
|
|
692
|
+
nonlocal pending, last_flush
|
|
693
|
+
if not on_messages or not pending:
|
|
694
|
+
return
|
|
695
|
+
if not force and len(pending) < 3 and time.time() - last_flush < 0.5:
|
|
696
|
+
return
|
|
697
|
+
batch = pending
|
|
698
|
+
pending = []
|
|
699
|
+
last_flush = time.time()
|
|
700
|
+
try:
|
|
701
|
+
on_messages(batch)
|
|
702
|
+
except Exception as e:
|
|
703
|
+
log.warning("flush acp messages failed: %s", e)
|
|
704
|
+
|
|
705
|
+
def on_message(msg: dict) -> None:
|
|
706
|
+
messages.append(msg)
|
|
707
|
+
pending.append(msg)
|
|
708
|
+
flush()
|
|
709
|
+
|
|
710
|
+
on_message.flush = flush # type: ignore[attr-defined]
|
|
711
|
+
return messages, on_message
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _acp_result_to_run_result(
|
|
715
|
+
*,
|
|
716
|
+
result: ACPPromptResult,
|
|
717
|
+
provider: str,
|
|
718
|
+
model: str,
|
|
719
|
+
work: str,
|
|
720
|
+
plan_label: str,
|
|
721
|
+
timeout: float,
|
|
722
|
+
messages: list[dict],
|
|
723
|
+
on_session: SessionPin | None,
|
|
724
|
+
) -> RunResult:
|
|
725
|
+
if result.session_id and on_session:
|
|
726
|
+
try:
|
|
727
|
+
on_session(result.session_id, work)
|
|
728
|
+
except Exception as e:
|
|
729
|
+
log.warning("pin acp session failed: %s", e)
|
|
730
|
+
|
|
731
|
+
usage_list = []
|
|
732
|
+
if result.usage and any(result.usage.values()):
|
|
733
|
+
usage_list.append(
|
|
734
|
+
{
|
|
735
|
+
"provider": provider,
|
|
736
|
+
"model": model,
|
|
737
|
+
"input_tokens": int(result.usage.get("input_tokens") or 0),
|
|
738
|
+
"output_tokens": int(result.usage.get("output_tokens") or 0),
|
|
739
|
+
"cache_read_tokens": int(result.usage.get("cache_read_tokens") or 0),
|
|
740
|
+
"cache_write_tokens": 0,
|
|
741
|
+
}
|
|
742
|
+
)
|
|
743
|
+
msgs = messages or list(result.messages or [])
|
|
744
|
+
common = dict(
|
|
745
|
+
work_dir=work,
|
|
746
|
+
sandbox_label=plan_label,
|
|
747
|
+
session_id=result.session_id,
|
|
748
|
+
messages=msgs,
|
|
749
|
+
usage=usage_list,
|
|
750
|
+
)
|
|
751
|
+
if result.status in ("aborted", "cancelled"):
|
|
752
|
+
return RunResult(
|
|
753
|
+
ok=False,
|
|
754
|
+
output=result.output,
|
|
755
|
+
error=result.error or "cancelled",
|
|
756
|
+
cancelled=True,
|
|
757
|
+
**common,
|
|
758
|
+
)
|
|
759
|
+
if result.status in ("timeout", "idle_watchdog"):
|
|
760
|
+
return RunResult(
|
|
761
|
+
ok=False,
|
|
762
|
+
output=result.output,
|
|
763
|
+
error=result.error or f"agent {result.status} after {timeout:.0f}s",
|
|
764
|
+
**common,
|
|
765
|
+
)
|
|
766
|
+
if result.status != "completed":
|
|
767
|
+
return RunResult(
|
|
768
|
+
ok=False,
|
|
769
|
+
output=result.output,
|
|
770
|
+
error=result.error or f"acp status={result.status}",
|
|
771
|
+
**common,
|
|
772
|
+
)
|
|
773
|
+
return RunResult(ok=True, output=result.output or "(no output)", **common)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _run_via_acp_pool(
|
|
777
|
+
*,
|
|
778
|
+
task: Task,
|
|
779
|
+
provider: str,
|
|
780
|
+
entry: AgentEntry,
|
|
781
|
+
work_dir: Path,
|
|
782
|
+
prompt: str,
|
|
783
|
+
timeout: float,
|
|
784
|
+
environ: dict[str, str],
|
|
785
|
+
plan_label: str,
|
|
786
|
+
acp_pool: ChatACPPool,
|
|
787
|
+
cancel_event: threading.Event | None,
|
|
788
|
+
on_messages: MessageFlush | None,
|
|
789
|
+
on_session: SessionPin | None,
|
|
790
|
+
idle_watchdog: float = 0,
|
|
791
|
+
system_prompt: str = "",
|
|
792
|
+
) -> RunResult | None:
|
|
793
|
+
"""Returns RunResult on ACP success/terminal status, or None to fall back."""
|
|
794
|
+
model = _resolve_model(entry, task)
|
|
795
|
+
agent_id = agent_id_from_task(task)
|
|
796
|
+
resume = ""
|
|
797
|
+
if not skip_cursor_chat_session_resume(provider, task.chat_session_id):
|
|
798
|
+
resume = task.prior_session_id
|
|
799
|
+
custom_args = _custom_args(task.agent)
|
|
800
|
+
work = str(work_dir)
|
|
801
|
+
messages, on_message = _acp_message_flusher(on_messages)
|
|
802
|
+
|
|
803
|
+
if on_session:
|
|
804
|
+
try:
|
|
805
|
+
on_session("", work)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
809
|
+
result = acp_pool.run_prompt(
|
|
810
|
+
provider=provider,
|
|
811
|
+
chat_session_id=task.chat_session_id,
|
|
812
|
+
agent_id=agent_id,
|
|
813
|
+
work_dir=work,
|
|
814
|
+
model=model,
|
|
815
|
+
prompt=prompt,
|
|
816
|
+
resume_session_id=resume,
|
|
817
|
+
custom_args=custom_args,
|
|
818
|
+
timeout=timeout,
|
|
819
|
+
env=environ,
|
|
820
|
+
cancel_event=cancel_event,
|
|
821
|
+
on_message=on_message,
|
|
822
|
+
idle_watchdog=idle_watchdog,
|
|
823
|
+
system_prompt=system_prompt,
|
|
824
|
+
)
|
|
825
|
+
|
|
826
|
+
# Open failed → caller falls back to one-shot.
|
|
827
|
+
if result.status == "failed" and "chat acp open failed" in (result.error or ""):
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
# Resume failed before a session was established → retry fresh once.
|
|
831
|
+
if result.status == "failed" and resume and not result.session_id:
|
|
832
|
+
log.warning(
|
|
833
|
+
"%s chat acp resume failed, retrying with fresh session: %s",
|
|
834
|
+
provider,
|
|
835
|
+
result.error,
|
|
836
|
+
)
|
|
837
|
+
acp_pool.remove_entry(pool_key(provider, task.chat_session_id, agent_id, work))
|
|
838
|
+
first_usage = result.usage
|
|
839
|
+
result = acp_pool.run_prompt(
|
|
840
|
+
provider=provider,
|
|
841
|
+
chat_session_id=task.chat_session_id,
|
|
842
|
+
agent_id=agent_id,
|
|
843
|
+
work_dir=work,
|
|
844
|
+
model=model,
|
|
845
|
+
prompt=prompt,
|
|
846
|
+
resume_session_id="",
|
|
847
|
+
custom_args=custom_args,
|
|
848
|
+
timeout=timeout,
|
|
849
|
+
env=environ,
|
|
850
|
+
cancel_event=cancel_event,
|
|
851
|
+
on_message=on_message,
|
|
852
|
+
idle_watchdog=idle_watchdog,
|
|
853
|
+
system_prompt=system_prompt,
|
|
854
|
+
)
|
|
855
|
+
if result.status == "failed" and "chat acp open failed" in (result.error or ""):
|
|
856
|
+
return None
|
|
857
|
+
if first_usage and result.usage:
|
|
858
|
+
result.usage = {
|
|
859
|
+
"input_tokens": int(first_usage.get("input_tokens") or 0)
|
|
860
|
+
+ int(result.usage.get("input_tokens") or 0),
|
|
861
|
+
"output_tokens": int(first_usage.get("output_tokens") or 0)
|
|
862
|
+
+ int(result.usage.get("output_tokens") or 0),
|
|
863
|
+
"cache_read_tokens": int(first_usage.get("cache_read_tokens") or 0)
|
|
864
|
+
+ int(result.usage.get("cache_read_tokens") or 0),
|
|
865
|
+
}
|
|
866
|
+
elif first_usage and not result.usage:
|
|
867
|
+
result.usage = first_usage
|
|
868
|
+
|
|
869
|
+
on_message.flush(force=True) # type: ignore[attr-defined]
|
|
870
|
+
return _acp_result_to_run_result(
|
|
871
|
+
result=result,
|
|
872
|
+
provider=provider,
|
|
873
|
+
model=model,
|
|
874
|
+
work=work,
|
|
875
|
+
plan_label=plan_label,
|
|
876
|
+
timeout=timeout,
|
|
877
|
+
messages=messages,
|
|
878
|
+
on_session=on_session,
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _run_via_acp_oneshot(
|
|
883
|
+
*,
|
|
884
|
+
task: Task,
|
|
885
|
+
provider: str,
|
|
886
|
+
entry: AgentEntry,
|
|
887
|
+
work_dir: Path,
|
|
888
|
+
prompt: str,
|
|
889
|
+
timeout: float,
|
|
890
|
+
environ: dict[str, str],
|
|
891
|
+
plan_label: str,
|
|
892
|
+
cancel_event: threading.Event | None,
|
|
893
|
+
on_messages: MessageFlush | None,
|
|
894
|
+
on_session: SessionPin | None,
|
|
895
|
+
idle_watchdog: float = 0,
|
|
896
|
+
system_prompt: str = "",
|
|
897
|
+
) -> RunResult:
|
|
898
|
+
model = _resolve_model(entry, task)
|
|
899
|
+
work = str(work_dir)
|
|
900
|
+
messages, on_message = _acp_message_flusher(on_messages)
|
|
901
|
+
resume = task.prior_session_id
|
|
902
|
+
if on_session:
|
|
903
|
+
try:
|
|
904
|
+
on_session("", work)
|
|
905
|
+
except Exception:
|
|
906
|
+
pass
|
|
907
|
+
|
|
908
|
+
result = run_acp_oneshot(
|
|
909
|
+
provider,
|
|
910
|
+
entry.path,
|
|
911
|
+
cwd=work,
|
|
912
|
+
prompt=prompt,
|
|
913
|
+
model=model,
|
|
914
|
+
resume_session_id=resume,
|
|
915
|
+
custom_args=_custom_args(task.agent),
|
|
916
|
+
timeout=timeout,
|
|
917
|
+
env=environ,
|
|
918
|
+
system_prompt=system_prompt,
|
|
919
|
+
on_message=on_message,
|
|
920
|
+
cancel_event=cancel_event,
|
|
921
|
+
idle_watchdog=idle_watchdog,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
# Resume failed before session → retry fresh once (matches Go daemon).
|
|
925
|
+
if result.status == "failed" and resume and not result.session_id:
|
|
926
|
+
log.warning(
|
|
927
|
+
"%s acp resume failed, retrying with fresh session: %s",
|
|
928
|
+
provider,
|
|
929
|
+
result.error,
|
|
930
|
+
)
|
|
931
|
+
first_usage = result.usage
|
|
932
|
+
result = run_acp_oneshot(
|
|
933
|
+
provider,
|
|
934
|
+
entry.path,
|
|
935
|
+
cwd=work,
|
|
936
|
+
prompt=prompt,
|
|
937
|
+
model=model,
|
|
938
|
+
resume_session_id="",
|
|
939
|
+
custom_args=_custom_args(task.agent),
|
|
940
|
+
timeout=timeout,
|
|
941
|
+
env=environ,
|
|
942
|
+
system_prompt=system_prompt,
|
|
943
|
+
on_message=on_message,
|
|
944
|
+
cancel_event=cancel_event,
|
|
945
|
+
idle_watchdog=idle_watchdog,
|
|
946
|
+
)
|
|
947
|
+
if first_usage and result.usage:
|
|
948
|
+
result.usage = {
|
|
949
|
+
"input_tokens": int(first_usage.get("input_tokens") or 0)
|
|
950
|
+
+ int(result.usage.get("input_tokens") or 0),
|
|
951
|
+
"output_tokens": int(first_usage.get("output_tokens") or 0)
|
|
952
|
+
+ int(result.usage.get("output_tokens") or 0),
|
|
953
|
+
"cache_read_tokens": int(first_usage.get("cache_read_tokens") or 0)
|
|
954
|
+
+ int(result.usage.get("cache_read_tokens") or 0),
|
|
955
|
+
}
|
|
956
|
+
elif first_usage and not result.usage:
|
|
957
|
+
result.usage = first_usage
|
|
958
|
+
|
|
959
|
+
on_message.flush(force=True) # type: ignore[attr-defined]
|
|
960
|
+
return _acp_result_to_run_result(
|
|
961
|
+
result=result,
|
|
962
|
+
provider=provider,
|
|
963
|
+
model=model,
|
|
964
|
+
work=work,
|
|
965
|
+
plan_label=plan_label,
|
|
966
|
+
timeout=timeout,
|
|
967
|
+
messages=messages,
|
|
968
|
+
on_session=on_session,
|
|
969
|
+
)
|