zai-coding-gateway 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.
@@ -0,0 +1,761 @@
1
+ """Сессия рабочего пространства: цикл need_more/done с Z.AI, темп-каталог, консолидация."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from zai_coding_gateway.client import (
10
+ chat_completions,
11
+ create_client,
12
+ get_fast_model,
13
+ get_work_model,
14
+ )
15
+ from zai_coding_gateway.errors import (
16
+ FilePathsRequiredError,
17
+ InstructionTooLongError,
18
+ ProjectRootNotSetError,
19
+ )
20
+ from zai_coding_gateway.tools.file_io import (
21
+ get_project_root,
22
+ glob_in_project,
23
+ grep_in_project,
24
+ list_dir_in_project,
25
+ read_file_content,
26
+ resolve_path,
27
+ )
28
+
29
+ # Консолидация: порог по токенам (приблизительно)
30
+ CONSOLIDATION_TRIGGER_TOKENS = 4000
31
+ CONSOLIDATION_KEEP_MESSAGES = 5
32
+ TRIM_TOKENS_TO_SUMMARIZE = 4000
33
+ SUMMARY_PREFIX = "Previous context (summarized):"
34
+ WORKSPACE_SESSIONS_DIR = "workspace_sessions"
35
+ WORKSPACE_SESSIONS_LOGS_DIR = "workspace_sessions_logs"
36
+
37
+ MAX_TOOL_ROUNDS = 5
38
+
39
+ # Состояния сессий, ожидающих ответа архитектора (вопрос по корню проекта или вопрос от модели).
40
+ _session_states: dict[str, dict[str, Any]] = {}
41
+
42
+ SYSTEM_WORKSPACE_BASE = """You are a coding assistant in a workspace session. You receive an instruction and file contents. You must respond with valid JSON only, no markdown.
43
+
44
+ Reply with one of four structures:
45
+
46
+ 1) If you need to explore the project (list directory, search text, find files by mask, read a file) use tools:
47
+ {"status": "use_tools", "tool_calls": [{"tool": "list_dir", "path": "src"}, {"tool": "grep", "pattern": "def ", "path": "src"}, {"tool": "glob", "pattern": "*.py"}, {"tool": "read", "path": "path/to/file.py"}]}
48
+ - list_dir: path (optional, default ".") — list direct children of a directory (one level).
49
+ - grep: pattern (required), path (optional) — search in file contents under path or whole project.
50
+ - glob: pattern (required) — e.g. "*.py", "**/test_*.py" — list matching file paths.
51
+ - read: path (required) — get file content (result shown in Tool results; use need_more to add to session files if needed).
52
+
53
+ 2) If you need more files added to the session context:
54
+ {"status": "need_more", "requested_paths": ["path/to/file1.py", "path/to/file2.py"], "reason": "brief reason"}
55
+
56
+ 3) When you have enough context and have produced your solution:
57
+ {"status": "done", "report": "text report: analysis, conclusions, recommendations", "suggested_changes": [{"path": "path/to/file.py", "diff": "unified diff or full new content"}, ...]}
58
+
59
+ 4) If you need to ask the architect a question (e.g. to add a file to session, or clarify something):
60
+ {"status": "question", "prompt": "Your question text here"}
61
+ The architect will reply via answer_session_question; the reply is then added to the next user message as "Architect answer: ...".
62
+
63
+ Rules:
64
+ - requested_paths and all paths in tool_calls must be relative to project root.
65
+ - In suggested_changes, the "path" field must be exactly one of the paths from the "Current session file paths" block below. Copy the path character-for-character; do not truncate or modify (e.g. use "package.json" not "package.").
66
+ - Use "diff" for unified diff when possible, or full file content if simpler.
67
+ """
68
+
69
+
70
+ def _build_system_content(
71
+ instruction: str,
72
+ todos: list[str],
73
+ allowed_paths: list[str],
74
+ ) -> str:
75
+ """
76
+ System: стабильная часть — роль, формат, инструкция, TODOs, пути сессии.
77
+ Не суммаризируется; передаётся заново при каждом запросе. Всё переменное — в user.
78
+ """
79
+ parts = [SYSTEM_WORKSPACE_BASE]
80
+ parts.append("Instruction (the task to accomplish):\n" + instruction)
81
+ parts.append("TODOs (complete as requested):\n" + "\n".join("- " + t for t in todos))
82
+ parts.append(
83
+ "Current session file paths — use exactly one of these in suggested_changes.path (copy as-is):\n"
84
+ + "\n".join(allowed_paths)
85
+ )
86
+ return "\n\n".join(parts)
87
+
88
+ SUMMARY_PROMPT_TEMPLATE = """Summarize the following conversation segment. Preserve: what was decided, what files were discussed, what is left to do. Output only the summary, no preamble. Conversation:
89
+ {messages}"""
90
+
91
+
92
+ def _approx_tokens(text: str) -> int:
93
+ """Приблизительный подсчёт токенов (~4 символа на токен)."""
94
+ return max(0, len(text) // 4)
95
+
96
+
97
+ def _get_workspace_sessions_root(project_root: str | None = None) -> str:
98
+ """Корень каталога сессий: project_root/workspace_sessions. project_root по умолчанию — get_project_root()."""
99
+ root = project_root if project_root is not None else get_project_root()
100
+ return os.path.join(root, WORKSPACE_SESSIONS_DIR)
101
+
102
+
103
+ def _get_session_logs_root() -> str:
104
+ """
105
+ Каталог для логов сессий. По умолчанию — проект gateway (чтобы разработчики видели результаты).
106
+ Если задана ZAI_SESSION_LOGS_DIR — туда (чтобы пользователь мог получать логи в свой проект по желанию).
107
+ """
108
+ env_dir = os.environ.get("ZAI_SESSION_LOGS_DIR", "").strip()
109
+ if env_dir:
110
+ return os.path.abspath(env_dir)
111
+ # По умолчанию: рядом с пакетом (repo root при разработке, или site-packages при установке)
112
+ import zai_coding_gateway
113
+
114
+ pkg_dir = os.path.dirname(os.path.abspath(zai_coding_gateway.__file__))
115
+ return os.path.abspath(os.path.join(pkg_dir, "..", "..", WORKSPACE_SESSIONS_LOGS_DIR))
116
+
117
+
118
+ def _get_session_log_path(session_id: str) -> str:
119
+ """Путь к файлу лога сессии: <session_logs_root>/<session_id>.log."""
120
+ logs_root = _get_session_logs_root()
121
+ return os.path.join(logs_root, f"{session_id}.log")
122
+
123
+
124
+ def _open_session_log(session_id: str): # noqa: ANN201
125
+ """Создаёт каталог логов и открывает файл лога для дополнения. При ошибке возвращает None."""
126
+ try:
127
+ log_path = _get_session_log_path(session_id)
128
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
129
+ return open(log_path, "a", encoding="utf-8")
130
+ except OSError:
131
+ return None
132
+
133
+
134
+ def _session_log_write(log_file: Any, line: str) -> None:
135
+ """Пишет строку в лог сессии. При ошибке молча пропускает (не ломает флоу)."""
136
+ if log_file is None:
137
+ return
138
+ try:
139
+ log_file.write(line)
140
+ if not line.endswith("\n"):
141
+ log_file.write("\n")
142
+ log_file.flush()
143
+ except OSError:
144
+ pass
145
+
146
+
147
+ def _run_tool_calls(
148
+ tool_calls: list[dict[str, Any]],
149
+ root: str,
150
+ ) -> tuple[str, list[str]]:
151
+ """
152
+ Выполняет предустановленные инструменты в пределах root. Возвращает (блок текста результатов, список ошибок).
153
+ """
154
+ results: list[str] = []
155
+ errors: list[str] = []
156
+ for i, call in enumerate(tool_calls):
157
+ if not isinstance(call, dict):
158
+ err = f"call_{i}: invalid (not a dict)"
159
+ errors.append(err)
160
+ results.append(f"{err}\n")
161
+ continue
162
+ tool = (call.get("tool") or "").strip().lower()
163
+ path_arg = (call.get("path") or "").strip() or "."
164
+ pattern_arg = (call.get("pattern") or "").strip()
165
+ try:
166
+ if tool == "list_dir":
167
+ names = list_dir_in_project(path_arg, root)
168
+ results.append(f"list_dir({path_arg!r}): {names!r}\n")
169
+ elif tool == "grep":
170
+ path_opt = path_arg if path_arg != "." else None
171
+ out = grep_in_project(pattern_arg or "", path_opt, root)
172
+ results.append(f"grep(pattern={pattern_arg!r}, path={path_opt!r}):\n{out}\n")
173
+ elif tool == "glob":
174
+ paths = glob_in_project(pattern_arg or "*", root)
175
+ results.append(f"glob({pattern_arg!r}): {paths!r}\n")
176
+ elif tool == "read":
177
+ content = read_file_content(path_arg, root)
178
+ preview = content[:2000] + "..." if len(content) > 2000 else content
179
+ results.append(f"read({path_arg!r}):\n{preview}\n")
180
+ else:
181
+ err = f"call_{i}: unknown tool {tool!r}"
182
+ errors.append(err)
183
+ results.append(f"{err}\n")
184
+ except Exception as e:
185
+ err = f"call_{i}: {tool}({path_arg!r}, pattern={pattern_arg!r}): {e!s}"
186
+ errors.append(err)
187
+ results.append(f"{err}\n")
188
+ return "".join(results), errors
189
+
190
+
191
+ def _session_dir(session_id: str, project_root: str | None = None) -> str:
192
+ """Абсолютный путь к темп-каталогу сессии."""
193
+ return os.path.join(_get_workspace_sessions_root(project_root), session_id)
194
+
195
+
196
+ def _ensure_session_dir(session_id: str, project_root: str | None = None) -> str:
197
+ """Создаёт каталог сессии, возвращает путь."""
198
+ path = _session_dir(session_id, project_root)
199
+ os.makedirs(path, exist_ok=True)
200
+ return path
201
+
202
+
203
+ def _add_file_to_session(session_dir: str, path: str, content: str) -> None:
204
+ """Записывает файл в темп-каталог сессии (path — относительный)."""
205
+ full = os.path.join(session_dir, path)
206
+ os.makedirs(os.path.dirname(full) or ".", exist_ok=True)
207
+ with open(full, "w", encoding="utf-8") as f:
208
+ f.write(content)
209
+
210
+
211
+ def _list_session_paths(session_dir: str) -> set[str]:
212
+ """Возвращает множество относительных путей файлов в каталоге сессии."""
213
+ result: set[str] = set()
214
+ root_real = os.path.realpath(session_dir)
215
+ for dirpath, _dirnames, filenames in os.walk(session_dir):
216
+ for name in filenames:
217
+ full = os.path.join(dirpath, name)
218
+ rel = os.path.relpath(full, session_dir)
219
+ result.add(rel)
220
+ return result
221
+
222
+
223
+ def _session_exists(session_id: str) -> bool:
224
+ """Проверяет, что каталог сессии существует."""
225
+ path = _session_dir(session_id)
226
+ return os.path.isdir(path)
227
+
228
+
229
+ def _build_files_block(files: dict[str, str]) -> str:
230
+ """Формирует блок «Files» для user-сообщения."""
231
+ if not files:
232
+ return ""
233
+ parts = [f"File: {p}\n```\n{c}\n```" for p, c in sorted(files.items())]
234
+ return "\n\n".join(parts)
235
+
236
+
237
+ def _build_user_content(
238
+ files_block: str,
239
+ summary_prefix: str | None = None,
240
+ summary: str | None = None,
241
+ ) -> str:
242
+ """
243
+ User: условная часть — содержимое файлов, суммаризованная история, ответы на вопросы.
244
+ Результат консолидации/суммаризации подставляется только сюда, не в system.
245
+ """
246
+ parts: list[str] = []
247
+ if summary_prefix and summary:
248
+ parts.append(f"{summary_prefix}\n{summary}")
249
+ parts.append("Files:\n" + (files_block or "(none)"))
250
+ return "\n\n".join(parts)
251
+
252
+
253
+ def _run_summarization(client: Any, messages_to_summarize: str) -> str:
254
+ """Вызов fast-модели для суммаризации фрагмента истории (по умолчанию glm-4.5-air)."""
255
+ prompt = SUMMARY_PROMPT_TEMPLATE.format(messages=messages_to_summarize)
256
+ resp = chat_completions(
257
+ client,
258
+ [{"role": "user", "content": prompt}],
259
+ model=get_fast_model(),
260
+ response_format=None,
261
+ )
262
+ content = (resp.get("choices") or [{}])[0].get("message", {}).get("content") or ""
263
+ return content.strip()
264
+
265
+
266
+ def _maybe_consolidate(
267
+ client: Any,
268
+ instruction: str,
269
+ files_block: str,
270
+ assistant_messages: list[str],
271
+ ) -> tuple[str, list[str]]:
272
+ """
273
+ Если история длинная — суммаризировать старые ответы, оставить последние K.
274
+ Возвращает (new_user_content, kept_assistant_messages).
275
+ """
276
+ if len(assistant_messages) <= CONSOLIDATION_KEEP_MESSAGES:
277
+ return _build_user_content(files_block), assistant_messages
278
+
279
+ to_keep = assistant_messages[-CONSOLIDATION_KEEP_MESSAGES:]
280
+ to_summarize = assistant_messages[: -CONSOLIDATION_KEEP_MESSAGES]
281
+ blob = "\n\n".join(to_summarize)
282
+ tokens = _approx_tokens(blob)
283
+ if tokens > TRIM_TOKENS_TO_SUMMARIZE:
284
+ max_chars = TRIM_TOKENS_TO_SUMMARIZE * 4
285
+ blob = blob[-max_chars:]
286
+ summary = _run_summarization(client, blob)
287
+ new_user = _build_user_content(files_block, SUMMARY_PREFIX, summary)
288
+ return new_user, to_keep
289
+
290
+
291
+ def _run_solve_in_workspace(
292
+ instruction: str,
293
+ todos: list[str],
294
+ file_paths: list[str],
295
+ max_steps: int,
296
+ project_root: str,
297
+ session_id: str | None = None,
298
+ resume_state: dict[str, Any] | None = None,
299
+ answer: str | None = None,
300
+ ) -> dict[str, Any]:
301
+ """
302
+ Внутренний цикл сессии: инициализация или восстановление из resume_state, затем цикл до done/question/break.
303
+ Ответ архитектора (answer) при восстановлении подставляется в user-сообщение.
304
+ """
305
+ from zai_coding_gateway.errors import MAX_INSTRUCTION_CHARS
306
+
307
+ root = project_root
308
+ if resume_state and answer is not None:
309
+ session_id = resume_state["session_id"]
310
+ instruction = resume_state["instruction"]
311
+ todos = resume_state["todos"]
312
+ session_files = resume_state["session_files"].copy()
313
+ assistant_messages = list(resume_state["assistant_messages"])
314
+ files_block = resume_state["files_block"]
315
+ system_content = resume_state["system_content"]
316
+ step = resume_state["step"]
317
+ tool_rounds = resume_state["tool_rounds"]
318
+ need_more_count = resume_state["need_more_count"]
319
+ tool_errors = list(resume_state["tool_errors"])
320
+ session_dir = resume_state["session_dir"]
321
+ user_content = _build_user_content(files_block) + "\n\nArchitect answer: " + answer
322
+ report = resume_state.get("report", "")
323
+ suggested_changes = list(resume_state.get("suggested_changes", []))
324
+ log_file = _open_session_log(session_id)
325
+ else:
326
+ session_files = {}
327
+ for path in file_paths:
328
+ resolve_path(path, root)
329
+ content = read_file_content(path, root)
330
+ session_files[path] = content
331
+ session_id = session_id or uuid.uuid4().hex
332
+ session_dir = _ensure_session_dir(session_id, root)
333
+ for path, content in session_files.items():
334
+ _add_file_to_session(session_dir, path, content)
335
+ log_file = _open_session_log(session_id)
336
+ _session_log_write(
337
+ log_file,
338
+ f"[start] session_id={session_id} instruction_len={len(instruction)} file_paths={list(session_files.keys())!r}",
339
+ )
340
+ files_block = _build_files_block(session_files)
341
+ session_paths = list(session_files.keys())
342
+ system_content = _build_system_content(instruction, todos, session_paths)
343
+ user_content = _build_user_content(files_block)
344
+ assistant_messages = []
345
+ report = ""
346
+ suggested_changes = []
347
+ step = 0
348
+ tool_rounds = 0
349
+ need_more_count = 0
350
+ tool_errors = []
351
+
352
+ client = create_client()
353
+
354
+ while step < max_steps:
355
+ total_tokens = _approx_tokens(instruction) + _approx_tokens(files_block)
356
+ for m in assistant_messages:
357
+ total_tokens += _approx_tokens(m)
358
+ if total_tokens >= CONSOLIDATION_TRIGGER_TOKENS and assistant_messages:
359
+ user_content, assistant_messages = _maybe_consolidate(
360
+ client, instruction, files_block, assistant_messages,
361
+ )
362
+
363
+ messages: list[dict[str, str]] = [
364
+ {"role": "system", "content": system_content},
365
+ {"role": "user", "content": user_content},
366
+ ]
367
+ for content in assistant_messages:
368
+ messages.append({"role": "assistant", "content": content})
369
+
370
+ _session_log_write(log_file, f"[step {step}] request: messages_count={len(messages)}")
371
+
372
+ resp = chat_completions(
373
+ client,
374
+ messages,
375
+ model=get_work_model(),
376
+ response_format={"type": "json_object"},
377
+ )
378
+ raw = (resp.get("choices") or [{}])[0].get("message", {}).get("content") or "{}"
379
+ try:
380
+ data = json.loads(raw)
381
+ except json.JSONDecodeError:
382
+ data = {"status": "need_more", "requested_paths": [], "reason": "Invalid JSON from model"}
383
+ status = data.get("status", "")
384
+
385
+ if status == "done":
386
+ report = data.get("report", "")
387
+ suggested_changes = data.get("suggested_changes") or []
388
+ _session_log_write(
389
+ log_file,
390
+ f"[step {step}] response: status=done report_len={len(report)} suggested_changes_count={len(suggested_changes)}",
391
+ )
392
+ break
393
+
394
+ if status == "question":
395
+ _session_states[session_id] = {
396
+ "phase": "awaiting_answer",
397
+ "project_root": root,
398
+ "session_id": session_id,
399
+ "instruction": instruction,
400
+ "todos": todos,
401
+ "file_paths": list(session_files.keys()),
402
+ "session_files": session_files,
403
+ "assistant_messages": assistant_messages + [raw],
404
+ "files_block": files_block,
405
+ "system_content": system_content,
406
+ "step": step,
407
+ "tool_rounds": tool_rounds,
408
+ "need_more_count": need_more_count,
409
+ "tool_errors": tool_errors,
410
+ "session_dir": session_dir,
411
+ "max_steps": max_steps,
412
+ "report": report,
413
+ "suggested_changes": suggested_changes,
414
+ }
415
+ _session_log_write(log_file, f"[step {step}] response: status=question prompt_len={len(data.get('prompt', ''))}")
416
+ try:
417
+ if log_file is not None:
418
+ log_file.close()
419
+ except OSError:
420
+ pass
421
+ return {"status": "question", "session_id": session_id, "prompt": data.get("prompt", "")}
422
+
423
+ if status == "use_tools":
424
+ tool_calls_raw = data.get("tool_calls") or []
425
+ if not isinstance(tool_calls_raw, list):
426
+ tool_calls_raw = []
427
+ tool_calls_list = [c for c in tool_calls_raw if isinstance(c, dict)]
428
+ if tool_rounds >= MAX_TOOL_ROUNDS:
429
+ tool_results_text = "Tool round limit (5) reached. Proceed with need_more or done."
430
+ round_errors = ["tool_rounds limit reached"]
431
+ tool_errors.extend(round_errors)
432
+ else:
433
+ tool_results_text, round_errors = _run_tool_calls(tool_calls_list, root)
434
+ tool_errors.extend(round_errors)
435
+ tool_rounds += 1
436
+ _session_log_write(
437
+ log_file,
438
+ f"[step {step}] response: status=use_tools tool_calls={tool_calls_list!r} results_len={len(tool_results_text)} errors={round_errors!r}",
439
+ )
440
+ assistant_messages.append(raw)
441
+ user_content = _build_user_content(files_block) + "\n\nTool results:\n" + tool_results_text
442
+ continue
443
+
444
+ if status not in ("need_more", ""):
445
+ _session_log_write(log_file, f"[step {step}] response: status={status!r} (unknown, treating as need_more)")
446
+ requested = data.get("requested_paths") or []
447
+ added_paths = []
448
+ need_more_errors = []
449
+ for path in requested:
450
+ if path in session_files:
451
+ continue
452
+ try:
453
+ resolve_path(path, root)
454
+ content = read_file_content(path, root)
455
+ session_files[path] = content
456
+ _add_file_to_session(session_dir, path, content)
457
+ added_paths.append(path)
458
+ except Exception as e:
459
+ need_more_errors.append(f"{path}: {e!s}")
460
+ added_any = len(added_paths) > 0
461
+ need_more_count += 1
462
+ _session_log_write(
463
+ log_file,
464
+ f"[step {step}] response: status=need_more requested_paths={requested!r} added={added_paths!r} errors={need_more_errors!r}",
465
+ )
466
+ files_block = _build_files_block(session_files)
467
+ system_content = _build_system_content(instruction, todos, list(session_files.keys()))
468
+ assistant_messages.append(raw)
469
+ step += 1
470
+ if not added_any and requested:
471
+ break
472
+
473
+ _session_log_write(
474
+ log_file,
475
+ f"[summary] steps={step} tool_rounds={tool_rounds} need_more_count={need_more_count} "
476
+ f"final_status={'done' if report else 'break'} tool_errors={tool_errors!r}",
477
+ )
478
+ try:
479
+ if log_file is not None:
480
+ log_file.close()
481
+ except OSError:
482
+ pass
483
+
484
+ return {
485
+ "session_id": session_id,
486
+ "report": report,
487
+ "suggested_changes": suggested_changes,
488
+ "files_used": list(session_files.keys()),
489
+ }
490
+
491
+
492
+ def solve_in_workspace(
493
+ instruction: str,
494
+ todos: list[str],
495
+ file_paths: list[str],
496
+ max_steps: int = 10,
497
+ ) -> dict[str, Any]:
498
+ """
499
+ Запускает сессию: загружает file_paths в темп-каталог, цикл need_more/done/question с Z.AI.
500
+ Если корень проекта не задан — возвращает status «question» с prompt про указание пути; ответ передаёте через answer_session_question.
501
+ Возвращает session_id, report, suggested_changes, files_used либо status «question», session_id, prompt.
502
+ """
503
+ from zai_coding_gateway.errors import MAX_INSTRUCTION_CHARS
504
+
505
+ if not file_paths:
506
+ raise FilePathsRequiredError("file_paths required for this task")
507
+ if len(instruction) > MAX_INSTRUCTION_CHARS:
508
+ raise InstructionTooLongError(
509
+ f"Instruction exceeds max size ({MAX_INSTRUCTION_CHARS} chars). Use file_paths for context."
510
+ )
511
+
512
+ try:
513
+ root = get_project_root()
514
+ except ProjectRootNotSetError:
515
+ session_id = uuid.uuid4().hex
516
+ _session_states[session_id] = {
517
+ "phase": "awaiting_root",
518
+ "instruction": instruction,
519
+ "todos": todos,
520
+ "file_paths": file_paths,
521
+ "max_steps": max_steps,
522
+ }
523
+ return {
524
+ "status": "question",
525
+ "session_id": session_id,
526
+ "prompt": "Укажите абсолютный путь к корню проекта для этой сессии.",
527
+ }
528
+
529
+ return _run_solve_in_workspace(
530
+ instruction, todos, file_paths, max_steps, project_root=root, session_id=None
531
+ )
532
+
533
+
534
+ def answer_session_question(session_id: str, answer: str) -> dict[str, Any]:
535
+ """
536
+ Передаёт ответ архитектора на вопрос сессии и продолжает выполнение.
537
+ Если сессия ожидала корень проекта — answer интерпретируется как абсолютный путь к корню.
538
+ Если сессия ожидала ответ на вопрос модели — answer подставляется в контекст и цикл продолжается.
539
+ """
540
+ from zai_coding_gateway.errors import SessionNotFoundError
541
+
542
+ state = _session_states.pop(session_id, None)
543
+ if not state:
544
+ raise SessionNotFoundError(f"Session not found or answer already submitted: {session_id!r}")
545
+ phase = state.get("phase", "")
546
+ answer_stripped = (answer or "").strip()
547
+ if phase == "awaiting_root":
548
+ if not answer_stripped:
549
+ _session_states[session_id] = state
550
+ raise ValueError("Ответ не должен быть пустым. Укажите абсолютный путь к корню проекта.")
551
+ root_abs = os.path.abspath(answer_stripped)
552
+ if not os.path.isdir(root_abs):
553
+ _session_states[session_id] = state
554
+ raise ValueError(f"Путь не является каталогом: {root_abs!r}")
555
+ return _run_solve_in_workspace(
556
+ state["instruction"],
557
+ state["todos"],
558
+ state["file_paths"],
559
+ state.get("max_steps", 10),
560
+ project_root=root_abs,
561
+ session_id=session_id,
562
+ )
563
+ if phase == "awaiting_answer":
564
+ return _run_solve_in_workspace(
565
+ state["instruction"],
566
+ state["todos"],
567
+ state["file_paths"],
568
+ state["max_steps"],
569
+ project_root=state["project_root"],
570
+ session_id=session_id,
571
+ resume_state=state,
572
+ answer=answer_stripped or answer,
573
+ )
574
+ _session_states[session_id] = state
575
+ raise SessionNotFoundError(f"Unknown session phase: {phase!r}")
576
+
577
+
578
+ def confirm_session_done(session_id: str) -> dict[str, Any]:
579
+ """
580
+ Очищает темп-каталог сессии после принятия решения архитектором.
581
+ Вызывать после apply_changes или при отказе от изменений.
582
+ """
583
+ from zai_coding_gateway.errors import SessionNotFoundError
584
+
585
+ sessions_root = _get_workspace_sessions_root()
586
+ session_path = _session_dir(session_id)
587
+ root_real = os.path.realpath(sessions_root)
588
+ path_real = os.path.realpath(session_path)
589
+ if not path_real.startswith(root_real) or not os.path.isdir(session_path):
590
+ raise SessionNotFoundError(f"Session not found or already cleaned: {session_id!r}")
591
+ shutil.rmtree(session_path, ignore_errors=True)
592
+ return {"session_id": session_id, "cleaned": True}
593
+
594
+
595
+ def apply_changes(
596
+ session_id: str,
597
+ changes: list[dict[str, str]],
598
+ confirm_after: bool = True,
599
+ ) -> dict[str, Any]:
600
+ """
601
+ Применяет утверждённые изменения к проекту: запись по path + content или path + diff.
602
+ Пути проверяются только на нахождение внутри корня проекта (ZAI_PROJECT_ROOT); новые файлы разрешены.
603
+ Каждое изменение обрабатывается отдельно: успешные — в applied, неудачи — в errors (path + причина).
604
+ При confirm_after после вызова очищает сессию (confirm_session_done).
605
+ """
606
+ from zai_coding_gateway.errors import SessionNotFoundError
607
+ from zai_coding_gateway.tools.file_io import read_file_content, write_file as write_file_impl
608
+
609
+ root = get_project_root()
610
+ session_path = _session_dir(session_id)
611
+ if not os.path.isdir(session_path):
612
+ raise SessionNotFoundError(f"Session not found: {session_id!r}")
613
+ applied: list[str] = []
614
+ errors: list[dict[str, str]] = []
615
+ for ch in changes:
616
+ path = (ch.get("path") or "").strip()
617
+ if not path:
618
+ continue
619
+ norm_path = path.replace("\\", "/")
620
+ try:
621
+ resolve_path(norm_path, root)
622
+ except Exception as e:
623
+ errors.append({"path": norm_path, "error": str(e)})
624
+ continue
625
+ content = ch.get("content")
626
+ diff = ch.get("diff")
627
+ if content is not None:
628
+ try:
629
+ write_file_impl(path, content, root)
630
+ applied.append(norm_path)
631
+ except Exception as e:
632
+ errors.append({"path": norm_path, "error": str(e)})
633
+ elif diff:
634
+ try:
635
+ current = read_file_content(path, root)
636
+ except FileNotFoundError:
637
+ current = ""
638
+ # Всегда пытаемся применить как unified diff (git diff начинается с "diff --git", не с "---").
639
+ # Никогда не записываем сырой текст diff в файл — это ломает проект.
640
+ new_content = _apply_unified_diff(current, diff)
641
+ if new_content is not None:
642
+ try:
643
+ write_file_impl(path, new_content, root)
644
+ applied.append(norm_path)
645
+ except Exception as e:
646
+ errors.append({"path": norm_path, "error": str(e)})
647
+ else:
648
+ errors.append({"path": norm_path, "error": "Diff does not apply to current file state"})
649
+ else:
650
+ errors.append({"path": norm_path, "error": "Missing both content and diff"})
651
+ if confirm_after:
652
+ try:
653
+ confirm_session_done(session_id)
654
+ except Exception:
655
+ pass
656
+ return {"session_id": session_id, "applied": applied, "errors": errors, "cleaned": confirm_after}
657
+
658
+
659
+ def _apply_unified_diff(current: str, diff: str) -> str | None:
660
+ """
661
+ Применяет unified diff к current. Сначала пробует patch; для git-диффов (diff --git)
662
+ при неудаче пробует git apply в темп-дереве. Никогда не записывает сырой diff в файл.
663
+ Возвращает новое содержимое или None при ошибке.
664
+ """
665
+ import subprocess
666
+ import tempfile
667
+
668
+ content = _apply_unified_diff_patch(current, diff)
669
+ if content is not None:
670
+ return content
671
+ return _apply_unified_diff_git_apply(current, diff)
672
+
673
+
674
+ def _apply_unified_diff_patch(current: str, diff: str) -> str | None:
675
+ """Пробует применить через patch (unified diff)."""
676
+ import subprocess
677
+ import tempfile
678
+
679
+ try:
680
+ fd_orig, path_orig = tempfile.mkstemp(suffix=".txt", text=True)
681
+ fd_patch, path_patch = tempfile.mkstemp(suffix=".patch", text=True)
682
+ try:
683
+ with os.fdopen(fd_orig, "w", encoding="utf-8") as f:
684
+ f.write(current)
685
+ with os.fdopen(fd_patch, "w", encoding="utf-8") as f:
686
+ f.write(diff)
687
+ result = subprocess.run(
688
+ ["patch", "-s", path_orig, "-i", path_patch],
689
+ capture_output=True,
690
+ text=True,
691
+ timeout=10,
692
+ )
693
+ if result.returncode != 0:
694
+ return None
695
+ with open(path_orig, encoding="utf-8") as f:
696
+ return f.read()
697
+ finally:
698
+ try:
699
+ os.unlink(path_orig)
700
+ except OSError:
701
+ pass
702
+ try:
703
+ os.unlink(path_patch)
704
+ except OSError:
705
+ pass
706
+ except Exception:
707
+ return None
708
+
709
+
710
+ def _apply_unified_diff_git_apply(current: str, diff: str) -> str | None:
711
+ """
712
+ Пробует применить через git apply в темп-директории (для диффов в формате git).
713
+ Создаёт файл с current по пути из diff, инициализирует git, применяет патч, возвращает результат.
714
+ """
715
+ import subprocess
716
+ import tempfile
717
+
718
+ if not diff.strip().startswith("diff "):
719
+ return None
720
+ first_file = _first_file_from_git_diff(diff)
721
+ if not first_file:
722
+ return None
723
+ try:
724
+ with tempfile.TemporaryDirectory(prefix="zai_apply_") as tmpdir:
725
+ target_path = os.path.join(tmpdir, first_file)
726
+ os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
727
+ with open(target_path, "w", encoding="utf-8") as f:
728
+ f.write(current)
729
+ patch_file = os.path.join(tmpdir, "change.patch")
730
+ with open(patch_file, "w", encoding="utf-8") as f:
731
+ f.write(diff)
732
+ subprocess.run(
733
+ ["git", "init", "-q"],
734
+ cwd=tmpdir,
735
+ capture_output=True,
736
+ timeout=5,
737
+ )
738
+ result = subprocess.run(
739
+ ["git", "apply", "--allow-empty", "--whitespace=warn", patch_file],
740
+ cwd=tmpdir,
741
+ capture_output=True,
742
+ text=True,
743
+ timeout=10,
744
+ )
745
+ if result.returncode != 0:
746
+ return None
747
+ if not os.path.isfile(target_path):
748
+ return None
749
+ with open(target_path, encoding="utf-8") as f:
750
+ return f.read()
751
+ except Exception:
752
+ return None
753
+
754
+
755
+ def _first_file_from_git_diff(diff: str) -> str | None:
756
+ """Из заголовка git diff извлекает путь к первому файлу (b/path)."""
757
+ for line in diff.splitlines():
758
+ s = line.strip()
759
+ if s.startswith("+++ b/"):
760
+ return s[6:].strip()
761
+ return None