-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopencode_session.py
More file actions
1369 lines (1251 loc) · 70.5 KB
/
Copy pathopencode_session.py
File metadata and controls
1369 lines (1251 loc) · 70.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""SSE-чтение событий сессии opencode и прогон агента (issue #53).
Выделено из opencode_runtime.py: подъём SSE-reader потока, отправка задачи,
ожидание исхода (idle/error/limit/timeout) и ретрай при лимите провайдера.
Импортирует базовые примитивы из opencode_base (ЛИСТ — без цикла с runtime) и
классификацию ошибок из opencode_errors. opencode_runtime ре-экспортирует
публичные имена (probe_session и др.) — потребители не меняются.
"""
import dataclasses
import json
import re
import sys
import threading
import time
from collections.abc import Callable, Mapping
import httpx
import httpx_sse
from opencode_base import (
POST_MESSAGE_READ_TIMEOUT,
PROVIDER_LIMIT_LOG_POLL_INTERVAL,
RATE_LIMIT_BACKOFF_BASE,
RATE_LIMIT_BACKOFF_CAP,
RATE_LIMIT_BACKOFF_FACTOR,
RATE_LIMIT_MAX_ATTEMPTS,
SSE_EVENT_READ_TIMEOUT,
SSE_IDLE_CHECK_TIMEOUT,
SSE_MAX_RECONNECTS,
SSE_READER_STARTUP_DELAY,
SSE_RECONNECT_DELAY,
SessionProbeResult,
Writer,
base_url,
)
from opencode_errors import (
FIRST_ACTION_TIMEOUT_REASON,
MODEL_NOT_IN_PLAN_REASON,
NETWORK_ERROR_REASON,
OUTPUT_LENGTH_REASON,
_is_model_not_in_plan,
_is_provider_limit_error,
_is_retryable_limit_error,
_opencode_error_tail,
public_reason,
)
from planning_questions import QuestionProtocolError, capture_question_request
from usage import (
Usage,
extract_session_usage,
extract_usage_from_message,
field,
merge_usages,
)
_PLAN_PATH_RE = re.compile(r"^Plan at (.+?) is complete\.", re.DOTALL)
_LENGTH_FINISH_REASONS = {
"length",
"max-tokens",
"max-output-tokens",
"model-length",
}
def _extract_session_id(payload: dict) -> str | None:
props = payload.get("properties", payload)
if not isinstance(props, dict):
return None
info = props.get("info")
if isinstance(info, dict):
sid = info.get("sessionID") or info.get("id")
if isinstance(sid, str) and sid.startswith("ses_"):
return sid
sid = props.get("sessionID")
if isinstance(sid, str):
return sid
return None
def _format_event(payload: dict) -> str | None:
etype = payload.get("type", "")
props = payload.get("properties", {})
if etype == "message.part.updated":
part = props.get("part", {})
ptype = part.get("type")
if ptype == "text":
return part.get("text", "")
if ptype == "tool":
tool = part.get("tool") or part.get("name") or "?"
state = (part.get("state") or {}).get("status", "")
return f"\n[tool: {tool} {state}]"
return None
if etype == "tool.execute.before":
return f"\n[tool start: {props.get('tool', '?')}]"
if etype == "tool.execute.after":
return f"\n[tool done: {props.get('tool', '?')}]"
if etype == "session.error":
return f"\n[SESSION ERROR] {json.dumps(props, ensure_ascii=False)[:300]}"
return None
def _rate_limit_backoff(attempt: int) -> float:
"""Пауза перед повтором: attempt 1 -> 5с, 2 -> 10, 3 -> 20, 4 -> 40 (потолок 60)."""
delay = RATE_LIMIT_BACKOFF_BASE * (RATE_LIMIT_BACKOFF_FACTOR ** (attempt - 1))
return min(delay, RATE_LIMIT_BACKOFF_CAP)
def _message_post_timeout(deadline: float | None, now: float) -> float:
if deadline is None:
return POST_MESSAGE_READ_TIMEOUT
remaining = deadline - now
if remaining <= 0:
return 0
return min(POST_MESSAGE_READ_TIMEOUT, remaining)
def _network_error_reason(operation: str, exc: BaseException) -> str:
"""Приватная transport-деталь поверх стабильной публичной категории."""
return (f"{NETWORK_ERROR_REASON}: {operation}: "
f"{type(exc).__name__}: {exc}")
def _error_text(props: dict) -> str:
err = props.get("error") or {}
if isinstance(err, str):
return err
if not isinstance(err, dict):
return "?"
data = err.get("data") or {}
msg = data.get("message") or err.get("message") or err.get("name") or "?"
code = data.get("statusCode")
return f"{msg}" + (f" (HTTP {code})" if code else "")
def _contains_output_length_error(value: object) -> bool:
"""Ищет типизированный MessageOutputLengthError в JSON/SDK payload.
Substring-матч по нормализованной (только a-z, нижний регистр) строке: имя
класса ищется внутри любого текста, а не как точное равенство целиком. Это
покрывает реальные формы, в которые _error_text оборачивает имя: с HTTP-кодом
(``MessageOutputLengthError (HTTP 500)``), с message-приоритетом над name, с
двоеточием и т.п. (cycle-2 review). Для typed info.error dict/list —
рекурсивно по значениям; для строки (output _error_text) — напрямую.
Допущение (cycle-3 review): info.error на терминальном assistant-сообщении и
SSE session.error несёт ТОЛЬКО текущую ошибку — без cause/history-цепочки
восстановленных предыдущих ошибок. Если будущий SDK начнёт вкладывать туда
историю, рекурсивный substring-матч сможет false-positive на случайно
упомянутом имени класса; тогда нужно будет матчить по верхнеуровневому
error.name, а не рекурсивно. На текущих формах (плоские {name,message,data})
риска нет.
"""
if isinstance(value, str):
normalized = re.sub(r"[^a-z]", "", value.lower())
return ("messageoutputlengtherror" in normalized
or "outputlengtherror" in normalized)
if isinstance(value, Mapping):
return any(_contains_output_length_error(item)
for item in value.values())
if isinstance(value, (list, tuple)):
return any(_contains_output_length_error(item) for item in value)
return False
def _normalize_finish_reason(value: object) -> str | None:
if not isinstance(value, str) or not value.strip():
return None
return value.strip().lower().replace("_", "-")
def _terminal_finish(message: object) -> tuple[str | None, bool]:
"""Возвращает terminal finish и признак исчерпания output budget."""
info = field(message, "info") or message
finish = None
for name in ("finish", "finishReason", "finish_reason"):
finish = _normalize_finish_reason(field(info, name))
if finish is not None:
break
raw_parts = field(message, "parts") or []
parts = raw_parts if isinstance(raw_parts, (list, tuple)) else []
for part in reversed(parts):
if field(part, "type") != "step-finish":
continue
if finish is None:
for name in ("reason", "finish", "finishReason", "finish_reason"):
finish = _normalize_finish_reason(field(part, name))
if finish is not None:
break
break
output_length_error = (
finish in _LENGTH_FINISH_REASONS
or _contains_output_length_error(field(info, "error"))
)
return finish, output_length_error
def _terminal_message(messages: object) -> object | None:
if not isinstance(messages, list):
info = field(messages, "info") or messages
return messages if field(info, "role") == "assistant" else None
for message in reversed(messages):
info = field(message, "info") or message
if field(info, "role") == "assistant":
return message
return None
def _fetch_session_terminal(
http: httpx.Client,
session_id: str,
write: Writer,
) -> tuple[Usage | None, str | None, bool, Usage | None]:
"""Best-effort session usage + terminal metadata одним GET /message."""
def note(msg: str) -> None:
write(f"\n[{msg}]\n")
print(f"[usage] {msg}", file=sys.stderr)
try:
resp = http.get(f"/session/{session_id}/message", timeout=10.0)
except Exception as exc:
note(f"usage: не удалось прочитать сообщения: {exc}")
return None, None, False, None
if resp.status_code >= 400:
note(f"usage: GET /message вернул HTTP {resp.status_code}")
return None, None, False, None
try:
messages = resp.json()
usage = extract_session_usage(messages)
terminal = _terminal_message(messages)
if terminal is None:
return usage, None, False, None
finish, output_length_error = _terminal_finish(terminal)
return (usage, finish, output_length_error,
extract_usage_from_message(terminal))
except Exception as exc:
note(f"usage: не удалось разобрать usage/finish: {exc}")
return None, None, False, None
def _fetch_session_usage(http: httpx.Client, session_id: str, write: Writer) -> Usage | None:
usage, _finish, _output_length_error, _terminal_usage = (
_fetch_session_terminal(http, session_id, write)
)
return usage
def _fetch_session_phase_usages(
http: httpx.Client,
session_id: str,
write: Writer,
) -> tuple[Usage | None, Usage | None]:
"""Best-effort usage split by native OpenCode assistant agent."""
try:
resp = http.get(f"/session/{session_id}/message", timeout=10.0)
resp.raise_for_status()
messages = resp.json()
except Exception as exc:
_safe_write(write, f"\n[usage: не удалось разделить plan/build: {exc}]\n")
return None, None
if not isinstance(messages, list):
return None, None
grouped: dict[str, list[Usage]] = {"plan": [], "build": []}
for message in messages:
info = field(message, "info") or message
if field(info, "role") != "assistant":
continue
agent = field(info, "agent") or field(info, "mode")
if agent not in grouped:
continue
usage = extract_usage_from_message(message)
if usage is not None:
grouped[str(agent)].append(usage)
return merge_usages(grouped["plan"]), merge_usages(grouped["build"])
def _safe_write(write: Writer, msg: str) -> None:
"""write может бросить, если лог уже закрыт (поток-reader живёт дольше)."""
try:
write(msg)
except (OSError, ValueError):
# Молчим осознанно: единственный потребитель этого сообщения — уже
# закрытый run.log (OSError/ValueError на закрытом файле); гнать ошибку
# некуда и она не диагностична. Узкий except: AttributeError/MemoryError
# и прочие баги должны всплыть, а не молча проглотиться.
pass
def _reply_to_question(base: str, payload: dict, responder: str,
attempt_idx: int, started: float) -> list[dict]:
"""Capture and synchronously answer one question.asked event.
Две принципиально разные ситуации при POST /question/<id>/reply:
* HTTPStatusError (4xx/5xx) — сервер ОТВЕТИЛ отказом. Это известный
детерминированный исход; GET /question reconciliation тут не нужен (мы
точно знаем, что ответ не принят). Сразу error, копия завершается code=2.
Раньше raise_for_status падал в общий except и ошибочно шёл в GET, где
запроса нет в pending — и копия получала ложный reply_status='replied'.
* TransportError/timeout — неизвестно, принял ли сервер POST. Тогда
осмотрительно сверяемся с GET /question: запроса уже нет в pending —
сервер успел принять и обработать → replied (без retry); запрос ещё в
pending (POST потерялся) → ОДИН retry POST; retry упал → error.
"""
properties = payload.get("properties") or {}
captured, answers = capture_question_request(
properties, responder, attempt_idx=attempt_idx,
elapsed=max(0.0, time.monotonic() - started),
)
request_id = properties["id"]
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
response = http.post(
f"/question/{request_id}/reply", json={"answers": answers})
response.raise_for_status()
except httpx.HTTPStatusError as exc:
message = public_reason(str(exc)) or f"HTTP {exc.response.status_code}"
for item in captured:
item["reply_status"] = "error"
item["reply_error"] = message
raise QuestionProtocolError(
f"question reply failed: {message}", captured) from exc
except Exception as exc:
# Transport/timeout: неизвестно, принял ли сервер POST. Сверяемся с
# состоянием очереди вопросов, прежде чем решать — отвечено или ретрай.
# HTTPStatusError уже обработан выше отдельной веткой (4xx/5xx — это
# известный отказ, reconciliation не нужен) и сюда не попадает.
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
pending = http.get("/question")
pending.raise_for_status()
pending_ids = {
str(item.get("id")) for item in pending.json()
if isinstance(item, dict)
}
except Exception as reconcile_exc:
# Не удалось узнать состояние очереди (HTTP-отказ или transport на
# GET) — не можем определить, принял ли сервер ответ. Безопасно error.
message = (public_reason(_retry_reason(reconcile_exc))
or reconcile_exc.__class__.__name__)
for item in captured:
item["reply_status"] = "error"
item["reply_error"] = message
raise QuestionProtocolError(
f"question reply failed: {message}", captured) from exc
if request_id not in pending_ids:
# Сервер принял и обработал ответ (в очереди его уже нет).
for item in captured:
item["reply_status"] = "replied"
return captured
# POST потерялся по transport, но вопрос ещё ждёт — один retry.
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
response = http.post(
f"/question/{request_id}/reply", json={"answers": answers})
response.raise_for_status()
except Exception as retry_exc:
message = (public_reason(_retry_reason(retry_exc))
or retry_exc.__class__.__name__)
for item in captured:
item["reply_status"] = "error"
item["reply_error"] = message
raise QuestionProtocolError(
f"question reply failed: {message}", captured) from exc
for item in captured:
item["reply_status"] = "replied"
return captured
def _reply_to_plan_exit(base: str, payload: dict) -> None:
"""Approve native plan_exit without recording it as a clarification."""
properties = payload.get("properties") or {}
request_id = properties.get("id")
if not request_id:
raise QuestionProtocolError("plan_exit question has no id")
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
response = http.post(
f"/question/{request_id}/reply",
json={"answers": [["Yes"]]},
)
response.raise_for_status()
except Exception as exc:
message = public_reason(_retry_reason(exc)) or exc.__class__.__name__
raise QuestionProtocolError(
f"plan_exit reply failed: {message}",
) from exc
def _abort_on_plan_exit(base: str, session_id: str, _payload: dict) -> None:
"""Keep --questions-only capture-only even if the planner calls plan_exit."""
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
response = http.post(f"/session/{session_id}/abort")
response.raise_for_status()
except Exception as exc:
message = public_reason(_retry_reason(exc)) or exc.__class__.__name__
raise QuestionProtocolError(
f"session abort failed: {message}",
) from exc
def _plan_path_from_request(payload: dict) -> str | None:
properties = payload.get("properties") or {}
questions = properties.get("questions") or []
if not questions or not isinstance(questions[0], dict):
return None
match = _PLAN_PATH_RE.match(str(questions[0].get("question") or ""))
return match.group(1) if match else None
def _is_plan_exit_request(payload: dict, result: dict) -> bool:
properties = payload.get("properties") or {}
tool_ref = properties.get("tool") or {}
call_id = tool_ref.get("callID") if isinstance(tool_ref, dict) else None
if call_id and result.get("tool_calls", {}).get(str(call_id)) == "plan_exit":
return True
# Compatibility fallback for OpenCode builds that omit tool-call mapping
# from SSE. Keep it deliberately strict so a user-authored Yes/No question
# is never mistaken for a control-plane transition.
questions = properties.get("questions") or []
if len(questions) != 1 or not isinstance(questions[0], dict):
return False
question = questions[0]
labels = [
str(option.get("label") or "")
for option in question.get("options") or []
if isinstance(option, dict)
]
return (
question.get("header") == "Build Agent"
and labels == ["Yes", "No"]
and _plan_path_from_request(payload) is not None
and "switch to the build agent" in str(question.get("question") or "")
)
def _capture_questions_and_abort(base: str, session_id: str, payload: dict,
responder: str, attempt_idx: int,
started: float) -> list[dict]:
"""Capture one question request without answering, then stop the session."""
properties = payload.get("properties") or {}
captured, _answers = capture_question_request(
properties, responder, attempt_idx=attempt_idx,
elapsed=max(0.0, time.monotonic() - started),
)
for item in captured:
item["answer"] = []
item["responder"] = "none"
item["fallback_used"] = False
item["reply_status"] = "captured"
item["reply_error"] = None
try:
with httpx.Client(base_url=base, timeout=30.0) as http:
response = http.post(f"/session/{session_id}/abort")
response.raise_for_status()
except Exception as exc:
message = public_reason(str(exc)) or exc.__class__.__name__
for item in captured:
item["reply_status"] = "error"
item["reply_error"] = message
raise QuestionProtocolError(
f"session abort failed: {message}", captured) from exc
return captured
def _retry_reason(exc: BaseException) -> str:
"""Текст причины retry-ошибки для санитайзинга (HTTPStatusError → по коду)."""
if isinstance(exc, httpx.HTTPStatusError):
return f"HTTP {exc.response.status_code}"
return str(exc)
def _session_looks_idle(base: str, session_id: str, write: Writer,
timeout: float = 10.0) -> bool:
"""True, если последнее assistant-сообщение сессии завершено (time.completed).
Используется когда SSE-стрим закрылся штатно, чтобы не пропустить
session.idle, случившийся в окне между закрытием и переподключением.
Консервативно: при любой неоднозначности возвращает False (→ реконнект),
чтобы никогда не выдать ещё работающую сессию за ложный успех.
`timeout` — таймаут синхронного GET. На пути реконнекта вызывающий передаёт
короткий SSE_IDLE_CHECK_TIMEOUT: иначе зависший (не упавший) сервер блокирует
reader-поток на весь таймаут × число реконнектов.
"""
try:
with httpx.Client(base_url=base, timeout=timeout) as http:
resp = http.get(f"/session/{session_id}/message")
if resp.status_code >= 400:
return False
messages = resp.json()
except Exception as exc:
# Консервативно False (→ реконнект), но оставляем след в обоих каналах:
# иначе ошибка доступа к сессии неотличима от штатного «ещё работает».
_safe_write(write, f"\n[idle-check: не удалось проверить сессию: {exc}]\n")
print(f"[idle-check] не удалось проверить сессию: {exc}", file=sys.stderr)
return False
if not isinstance(messages, list) or not messages:
return False
for entry in reversed(messages):
info = field(entry, "info")
if info is None:
info = entry
if field(info, "role") != "assistant":
continue
# Output-length error — terminal-состояние, которое классификатор после
# done перечитает и превратит в точную ошибку. Остальные ошибки не считаем
# idle: потерянный session.error нельзя выдать за code 0.
_finish, output_length_error = _terminal_finish(entry)
if field(info, "error") and not output_length_error:
return False
time_info = field(info, "time") or {}
# сессия закончила работу: последнее assistant-сообщение завершено.
return bool(field(time_info, "completed"))
return False
def _record_message_context(payload: dict, result: dict) -> None:
"""Запоминает role/agent messageID для фильтра глобальной SSE-шины."""
etype = payload.get("type")
props = payload.get("properties") or {}
if etype == "message.part.updated":
part = props.get("part") or {}
part_id = part.get("id") or part.get("partID")
part_type = part.get("type")
if isinstance(part_id, str) and isinstance(part_type, str):
result.setdefault("part_types", {})[part_id] = part_type
return
if etype != "message.updated":
return
info = props.get("info") or props.get("message") or {}
if not isinstance(info, dict):
return
message_id = info.get("id") or info.get("messageID")
if not isinstance(message_id, str):
return
result.setdefault("message_context", {})[message_id] = {
"role": info.get("role"),
"agent": info.get("agent") or info.get("mode"),
}
def _record_first_action(payload: dict, result: dict) -> None:
"""Записывает первый text/tool/question сигнал, не считая reasoning."""
if "first_action_elapsed" in result:
return
etype = payload.get("type", "")
props = payload.get("properties") or {}
is_action = etype in {
"tool.execute.before",
"tool.execute.after",
"question.asked",
}
def assistant_matches(message_id: object) -> bool:
"""True, если сообщение — assistant основного агента (не user/title)."""
context = (result.get("message_context") or {}).get(message_id)
if not context or context.get("role") != "assistant":
return False
primary_agent = result.get("agent")
event_agent = context.get("agent")
if primary_agent and event_agent and event_agent != primary_agent:
return False
return True
if etype == "message.part.updated":
part = props.get("part") or {}
ptype = part.get("type")
message_id = part.get("messageID") or props.get("messageID")
if assistant_matches(message_id):
pass
elif ptype == "text":
# Неизвестный text-part может быть пользовательским prompt/title.
# Tool без context безопаснее считать действием, text — нет.
return
is_action = ptype == "tool" or (
ptype == "text" and bool(str(part.get("text") or "").strip())
)
elif etype == "message.part.delta":
# OpenCode шлёт text-delta отдельно; reasoning-delta намеренно не
# считается действием, иначе watchdog теряет смысл.
part = props.get("part") or {}
message_id = part.get("messageID") or props.get("messageID")
if not assistant_matches(message_id):
return
field_name = props.get("field")
part_id = part.get("id") or props.get("partID")
part_type = (part.get("type")
or (result.get("part_types") or {}).get(part_id))
is_action = field_name == "text" and part_type == "text"
if not is_action:
return
started = result.get("started")
if isinstance(started, (int, float)):
result["first_action_elapsed"] = max(0.0, time.monotonic() - started)
def _record_terminal_event(payload: dict, result: dict) -> bool:
"""Сохраняет terminal finish из message.updated; True для output length."""
if payload.get("type") != "message.updated":
return False
props = payload.get("properties") or {}
info = props.get("info") or props.get("message") or {}
if not isinstance(info, dict):
return False
time_info = info.get("time") or {}
if not (isinstance(time_info, dict) and time_info.get("completed")):
return False
# info — уже словарь сообщения; _terminal_finish сам делает
# field(message,"info") or message, так что передаём его напрямую, без
# синтеза throwaway-обёртки {"info": info, "parts": []}. Шаг-parts здесь не
# нужны: terminal-событие уже завершилось, нас интересует только finish.
finish, output_length_error = _terminal_finish(info)
if finish is not None:
result["finish_reason"] = finish
if output_length_error:
result["output_length_error"] = True
return output_length_error
def _sse_reader(base: str, session_id: str, done: threading.Event,
stop: threading.Event, result: dict, write: Writer,
deadline: float | None = None,
question_handler: Callable[[dict], list[dict]] | None = None,
stop_after_question: bool = False,
plan_exit_handler: Callable[[dict], None] | None = None) -> None:
reconnects = 0
while not stop.is_set():
if deadline is not None and time.monotonic() >= deadline:
# Бюджет исчерпан — пусть основной цикл вынесет честный таймаут.
return
try:
sse_timeout = httpx.Timeout(
connect=10.0, read=SSE_EVENT_READ_TIMEOUT, write=10.0, pool=10.0)
with httpx.Client(timeout=sse_timeout) as client:
with httpx_sse.connect_sse(client, "GET", f"{base}/event") as source:
for sse in source.iter_sse():
if stop.is_set():
return
try:
payload = json.loads(sse.data)
except (json.JSONDecodeError, TypeError):
# Служебные/keepalive SSE-кадры без JSON — пропускаем
# осознанно; настоящие ошибки сессии приходят
# отдельным session.error и логируются ниже.
continue
sid = _extract_session_id(payload)
if sid and sid != session_id:
continue
etype = payload.get("type", "")
_record_message_context(payload, result)
_record_first_action(payload, result)
# message.updated с completed+length — уже terminal-
# сигнал. Не ждём потерянный session.idle: основной поток
# немедленно перечитает GET /message и классифицирует.
if _record_terminal_event(payload, result):
done.set()
return
if etype == "message.part.updated":
part = (payload.get("properties") or {}).get("part") or {}
if part.get("type") == "tool" and part.get("callID"):
result.setdefault("tool_calls", {})[
str(part["callID"])
] = str(part.get("tool") or part.get("name") or "")
if etype == "question.asked" and question_handler is not None:
request_id = str(
(payload.get("properties") or {}).get("id") or "")
if (_is_plan_exit_request(payload, result)
and plan_exit_handler is not None):
seen_control = result.setdefault(
"control_question_request_ids", set())
if request_id in seen_control:
continue
if request_id:
seen_control.add(request_id)
try:
plan_exit_handler(payload)
result["plan_path"] = _plan_path_from_request(
payload)
started = result.get("started")
if isinstance(started, (int, float)):
result["plan_elapsed"] = max(
0.0, time.monotonic() - started)
result["plan_completed"] = True
if stop_after_question:
result["questions_only_complete"] = True
done.set()
return
except QuestionProtocolError as exc:
result["error"] = str(exc)
done.set()
return
continue
seen = result.setdefault("question_request_ids", set())
if request_id and request_id not in seen:
seen.add(request_id)
try:
items = question_handler(payload)
round_idx = len(seen)
for item in items:
item["round_idx"] = round_idx
result.setdefault("questions", []).extend(items)
if stop_after_question:
result["questions_only_complete"] = True
done.set()
return
except QuestionProtocolError as exc:
round_idx = len(seen)
for item in exc.questions:
item["round_idx"] = round_idx
result.setdefault("questions", []).extend(exc.questions)
result["error"] = str(exc)
done.set()
return
msg = _format_event(payload)
if msg:
write(msg if etype == "message.part.updated" else msg + "\n")
if etype == "session.error" and sid == session_id:
result["error"] = _error_text(payload.get("properties", {}))
done.set()
return
if etype == "session.idle" and sid == session_id:
done.set()
return
# iter_sse исчерпан ШТАТНО без финального события сессии.
except Exception as exc:
# Сетевой обрыв соединения — это ошибка. Реконнектим, пока есть
# бюджет; если бюджет исчерпан или слишком много обрывов подряд —
# фиксируем ошибку (битый SSE != молчаливый таймаут).
if stop.is_set():
return
# session.idle мог прийтись на окно обрыва (или тихий период до
# ReadTimeout) — проверяем статус сессии, как и при graceful-close,
# иначе завершившийся прогон превратится в ложный таймаут/ошибку.
if _session_looks_idle(base, session_id, write,
timeout=SSE_IDLE_CHECK_TIMEOUT):
done.set()
return
if stop.is_set():
return
reconnects += 1
# Если до дедлайна не успеем переподключиться — нет смысла ждать,
# фиксируем ошибку сразу (битый SSE != молчаливый таймаут).
no_budget_left = (deadline is not None
and deadline - time.monotonic() <= SSE_RECONNECT_DELAY)
if reconnects > SSE_MAX_RECONNECTS or no_budget_left:
if isinstance(exc, httpx.TransportError):
result["error"] = _network_error_reason(
"SSE reader error /event", exc)
else:
# Не маскируем неожиданный программный сбой под сеть.
result["error"] = f"SSE reader error: {exc}"
_safe_write(write, f"\n[SSE reader error] {exc}\n")
done.set()
return
_safe_write(write, f"\n[SSE: соединение оборвалось ({exc}), переподключаюсь]\n")
stop.wait(SSE_RECONNECT_DELAY)
continue
# --- штатное закрытие стрима сервером без session.idle/session.error ---
# Это НЕ ошибка: стрим GET /event — глобальная шина, сервер может его
# gracefully закрыть, пока сессия ещё работает. Реконнектим, пока есть
# бюджет; при исчерпании бюджета/лимита реконнектов просто выходим молча,
# чтобы основной цикл вынес ЧЕСТНЫЙ таймаут (а не подменяем его ошибкой).
if stop.is_set() or done.is_set():
return
_safe_write(write, "\n[SSE: сервер закрыл /event без session.idle, "
"проверяю статус сессии и переподключаюсь]\n")
if _session_looks_idle(base, session_id, write,
timeout=SSE_IDLE_CHECK_TIMEOUT):
done.set()
return
if stop.is_set():
return
reconnects += 1
if reconnects > SSE_MAX_RECONNECTS:
return
if deadline is not None and time.monotonic() >= deadline:
return
stop.wait(SSE_RECONNECT_DELAY)
def probe_session(task: str, model: str, provider: str, agent: str, timeout: float,
port: int, write: Writer, planning: bool = False,
question_responder: str = "recommended",
questions_only: bool = False,
first_action_timeout: float = 0.0) -> SessionProbeResult:
"""Гоняет сессию агента, ретраит при лимите провайдера с backoff.
`timeout` — бюджет wall-clock ВСЕЙ копии, общий на все попытки, включая
backoff-паузы между ними (issue #139). Абсолютный deadline считается здесь
один раз и передаётся в каждую попытку; ретрай не стартует, если бюджет уже
исчерпан. После исчерпания ретраев (или бюджета) — отдельный статус «лимит»
(code=3), а не обычная «ошибка».
"""
# Стартовая пауза SSE-reader идёт «сверх» бюджета (см. _probe_session_once):
# при коротком timeout иначе дедлайн истёк бы ещё до отправки задачи.
deadline = (None if timeout <= 0
else time.monotonic() + SSE_READER_STARTUP_DELAY + timeout)
# Цикл всегда делает ≥1 итерацию (RATE_LIMIT_MAX_ATTEMPTS >= 1), а выйти из
# него без return можно лишь через rate_limited-результат → `last` тут не None.
last = None
all_questions: list[dict] = []
for attempt in range(1, RATE_LIMIT_MAX_ATTEMPTS + 1):
# Все probe-options передаются как keyword args с дефолтами в
# _probe_session_once — трёхветочный dispatch был бы мёртвым кодом
# (передать дефолт = опустить). Когда добавляется новый knob, его
# просто вписывают сюда же одной строкой.
res = _probe_session_once(
task, model, provider, agent, timeout, port, write,
planning=planning,
question_responder=question_responder,
questions_only=questions_only,
first_action_timeout=first_action_timeout,
attempt_idx=attempt,
deadline=deadline,
)
all_questions.extend(res.questions)
if not res.rate_limited:
return SessionProbeResult(
res.code, res.reason, res.usage, res.rate_limited,
tuple(all_questions),
res.plan_path, res.plan_elapsed, res.build_elapsed,
res.plan_usage, res.build_usage, res.plan_completed,
res.post_hung, res.finish_reason, res.first_action_elapsed,
)
last = res
if attempt < RATE_LIMIT_MAX_ATTEMPTS:
delay = _rate_limit_backoff(attempt)
# Бюджет копии общий: если после паузы времени на попытку уже не
# останется, ретраить незачем — иначе копия шла бы кратно дольше
# --timeout (issue #139).
if deadline is not None and time.monotonic() + delay >= deadline:
write("\n[rate limit] бюджет --timeout исчерпан, "
"ретраи прекращены\n")
break
write(f"\n[rate limit] попытка {attempt}/{RATE_LIMIT_MAX_ATTEMPTS} "
f"упёрлась в лимит провайдера, жду {delay:.0f}с и повторяю...\n")
time.sleep(delay)
write("\n--- лимит провайдера: retry исчерпан ---\n")
assert last is not None
return SessionProbeResult(
3, last.reason, last.usage, questions=tuple(all_questions),
plan_path=last.plan_path, plan_elapsed=last.plan_elapsed,
build_elapsed=last.build_elapsed, plan_usage=last.plan_usage,
build_usage=last.build_usage, plan_completed=last.plan_completed,
)
def _exit_state(result: dict, done: threading.Event) -> str | None:
"""Причина выйти из poll-loop: 'error' (reader сообщил ошибку) или 'idle'
(сессия завершилась). None — продолжаем ждать. 'error' имеет приоритет."""
if result.get("error"):
return "error"
if done.is_set():
return "idle"
return None
def _wait_for_session(
done: threading.Event,
result: dict,
deadline: float | None,
provider_limit_tail: Callable[[], str | None],
*,
first_action_timeout: float = 0.0,
) -> tuple[str, str | None]:
"""Ждёт исхода сессии. Возвращает (outcome, limit_tail):
'error' — reader сообщил ошибку (result['error']);
'idle' — сессия завершилась (done);
'limit' — в логе opencode найден лимит провайдера (limit_tail задан);
'first_action_timeout' — watchdog не увидел text/tool/question;
'deadline' — истёк дедлайн.
error/idle проверяются и до, и после чтения лога (оно делает I/O, за время
которого сессия может завершиться) — поэтому _exit_state зовётся дважды.
NB: 'idle' из done.wait() может гонкой совпасть с выставленным reader'ом
result['error'] (его тут уже не перепроверяем). Поэтому вызывающий после
'idle' ОБЯЗАН сначала проверить result.get('error') (error-first)."""
while True:
state = _exit_state(result, done)
if state:
return state, None
limit_tail = provider_limit_tail()
state = _exit_state(result, done)
if state:
return state, None
if limit_tail:
return "limit", limit_tail
wait_for = PROVIDER_LIMIT_LOG_POLL_INTERVAL
if (first_action_timeout > 0
and "first_action_elapsed" not in result):
started = result.get("started")
if isinstance(started, (int, float)):
action_remaining = started + first_action_timeout - time.monotonic()
if action_remaining <= 0:
return "first_action_timeout", None
wait_for = min(wait_for, action_remaining)
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
return "deadline", None
wait_for = min(wait_for, remaining)
if done.wait(timeout=wait_for):
return "idle", None
def _provider_error_tail(session_id: str, agent: str, write: Writer) -> str | None:
"""Хвост ошибок провайдера из лога opencode (с agent= и fallback без него).
Найденный tail копируется в лог копии. Вынесено из замыкания внутри
_probe_session_once (#74): захватывало только session_id/agent/write."""
tail = (_opencode_error_tail(session_id, agent=agent)
or _opencode_error_tail(session_id))
if tail:
write("\n--- ошибки провайдера из лога opencode ---\n"
f"{tail}\n")
return tail
def _provider_limit_tail(session_id: str, agent: str, write: Writer) -> str | None:
"""Хвост лога, только если это лимит провайдера (для in-loop детекта в
_wait_for_session). Вынесено из замыкания (#74)."""
tail = _opencode_error_tail(session_id, agent=agent)
if not tail or not _is_provider_limit_error(tail):
return None
write("\n--- лимит провайдера из лога opencode ---\n"
f"{tail}\n")
return tail
def _with_tail(reason: str, session_id: str, agent: str, write: Writer) -> str:
"""Дополняет reason первой строкой tail-а провайдера, если она привносит сигнал
(не дублирует уже присутствующий). Вынесено из замыкания (#74)."""
tail = _provider_error_tail(session_id, agent, write)
if not tail:
return reason
first_line = tail.splitlines()[0]
sig = max(first_line.split(" | "), key=len).strip()
if sig and sig in reason:
return reason
return f"{reason} | {first_line}"
def _open_session(http: httpx.Client, agent: str, provider: str, model: str,
write: Writer) -> str | SessionProbeResult:
"""Создаёт сессию и валидирует ответ. Возвращает session_id либо ранний
error-result (если сервер вернул не-dict / dict без id)."""
write(f"Создаю сессию (агент: {agent})...\n")
resp = http.post("/session", json={})
try:
sess = resp.json()
except Exception:
sess = None
# Сервер может вернуть не-dict (строку ошибки, null) или dict без "id":
# тогда sess["id"] упал бы KeyError/TypeError, а reader-поток ещё не
# запущен — отдаём честную ошибку вместо краша.
if not isinstance(sess, dict) or "id" not in sess:
reason = f"неожиданный ответ POST /session (HTTP {resp.status_code}): {sess!r:.200}"
write(f"\n--- ошибка ---\n[{reason}]\n")
return SessionProbeResult(2, reason)
session_id = sess["id"]
write(f"Сессия: {session_id}\n")
write(f"Модель: {provider}/{model}\n")
write("--- работа ---\n")
return session_id
def _post_task(http: httpx.Client, session_id: str, agent: str, body: dict,
deadline: float | None, write: Writer
) -> tuple[Usage | None, SessionProbeResult | None, bool]:
"""POST задачи агенту + классификация НЕМЕДЛЕННЫХ ошибок (HTTP≥400 / info.error).
Возвращает (usage, result, post_hung): result=None — немедленной ошибки нет,
продолжаем ждать события до дедлайна. POST пропускается, если бюджет уже истёк
(post_timeout<=0).
ReadTimeout сам по себе не ошибка (события могут прийти позже), но факт
«ответа на POST не было» поднимается наружу третьим элементом — post_hung
(issue #124, угол C). Раньше он оставался только маркером в run.log, и
сессия, закрывшаяся после этого по idle, отдавала code=0 «готово» — ложный
успех без единого артефакта. Решение принимает _classify_outcome.
Достоверно pre-dispatch ConnectError/ConnectTimeout/PoolTimeout сразу дают
code=2 (#158). Остальные httpx.TransportError неоднозначны: serve мог принять
POST, поэтому result несёт network fallback, а post_hung=True разрешает
вызывающему предпочесть уже выставленный SSE-исход (PR #159 cycle 1)."""
usage: Usage | None = None
post_timeout = _message_post_timeout(deadline, time.monotonic())
if post_timeout <= 0:
# Бюджет истёк ещё до отправки — POST не делался. Это не «зависший»