File size: 41,574 Bytes
bab19d3
 
f356427
 
8312ded
 
bab19d3
 
 
 
55ffb85
 
 
 
 
 
 
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
639bbdd
bab19d3
 
 
 
 
 
 
 
 
 
639bbdd
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66ab8ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bab19d3
 
12032b1
bab19d3
 
 
 
 
 
 
 
 
 
 
 
639bbdd
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12032b1
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
 
 
 
 
199daad
bab19d3
 
 
 
 
 
66ab8ad
 
 
f356427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcea8ab
 
 
 
 
 
 
 
f356427
 
 
 
 
dcea8ab
f356427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55ffb85
 
 
 
 
 
 
 
 
 
 
 
bab19d3
8312ded
 
 
 
 
 
 
 
 
 
 
 
 
 
7a4c81d
 
 
 
f356427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcea8ab
 
 
 
 
 
 
 
 
 
 
 
 
 
f356427
 
 
 
 
 
 
 
 
 
 
 
7a4c81d
f356427
 
 
7a4c81d
 
f356427
 
 
 
 
 
7a4c81d
f356427
 
7a4c81d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66ab8ad
 
 
 
 
 
55ffb85
 
 
 
 
 
 
 
 
66ab8ad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55ffb85
 
 
 
66ab8ad
55ffb85
66ab8ad
55ffb85
66ab8ad
55ffb85
66ab8ad
 
55ffb85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66ab8ad
52d4712
 
 
 
66ab8ad
55ffb85
 
 
 
 
 
 
 
 
52d4712
e5e9d87
52d4712
 
 
 
 
 
588aebe
 
 
 
 
 
 
52d4712
 
 
588aebe
 
 
52d4712
 
 
 
bab19d3
55ffb85
 
 
bab19d3
66ab8ad
 
55ffb85
66ab8ad
55ffb85
66ab8ad
bab19d3
55ffb85
 
bab19d3
 
 
 
 
66ab8ad
 
55ffb85
 
 
 
 
 
66ab8ad
 
55ffb85
66ab8ad
 
 
bab19d3
2ed91ed
 
 
 
 
 
 
bab19d3
7af5a14
 
 
 
 
bab19d3
 
 
 
 
 
 
 
 
 
 
 
 
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
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
from __future__ import annotations

import os

from services.agent_firewall import server_management_refusal

# P0 chat runtime service.
# Dependencies are injected once from ui_server during app assembly so the
# large streaming/runtime logic is no longer owned by the entry module.

from services.context_compressor import (
    build_request_spec_from_messages,
    extract_request_spec,
    merge_request_spec,
    render_request_spec,
)

def _inject(context):
    globals().update(context)

async def stream_chat(tid,prompt):
    global last_llm_error
    usage_user_id=thread_user_ids.get(tid,"")

    pending_uploads=thread_upload_ids.pop(tid,[])
    upload_context=""

    if usage_user_id and pending_uploads:
        upload_context=await build_user_upload_context(
            usage_user_id,
            pending_uploads,
        )

        if upload_context:
            asyncio.create_task(
                safe_memory_event(
                    usage_user_id,
                    "attachment_used",
                    {
                        "thread_id":tid,
                        "upload_ids":pending_uploads[:10],
                        "count":len(pending_uploads[:10]),
                    },
                )
            )
    processing_context=""

    if (
        usage_user_id
        and pending_uploads
        and _quality_check_requested(prompt)
    ):
        task_id="proc_"+secrets.token_hex(8)

        await safe_memory_event(
            usage_user_id,
            "processing_started",
            {
                "task_id":task_id,
                "thread_id":tid,
                "operation":"quality_check",
                "upload_ids":
                    pending_uploads[:10],
                "status":"running",
            },
        )

        yield out(
            "status",
            {
                "text":
                    "正在使用本地 Python 检查上传数据…"
            },
        )

        try:
            processing_result=await asyncio.to_thread(
                _run_upload_quality_checks,
                usage_user_id,
                pending_uploads,
            )

            record=_compact_processing_record(
                processing_result
            )

            await safe_memory_event(
                usage_user_id,
                "processing_completed",
                {
                    "task_id":task_id,
                    "thread_id":tid,
                    "operation":
                        "quality_check",
                    "upload_ids":
                        pending_uploads[:10],
                    "status":"completed",
                    "result":record,
                },
            )

            processing_context=(
                "[USER_DATA_PROCESSING_RESULT]\n"
                "The following result was computed "
                "locally with Python from the user's "
                "uploaded file. Treat these computed "
                "values as authoritative for this "
                "answer. Do not estimate them from "
                "the raw file. Explain the result "
                "clearly in Chinese.\n"
                + json.dumps(
                    record,
                    ensure_ascii=False,
                    default=str,
                )
                + "\n[/USER_DATA_PROCESSING_RESULT]"
            )

            yield out(
                "status",
                {
                    "text":
                        "数据质检完成,正在整理结果…"
                },
            )

        except Exception as exc:
            await safe_memory_event(
                usage_user_id,
                "processing_failed",
                {
                    "task_id":task_id,
                    "thread_id":tid,
                    "operation":
                        "quality_check",
                    "upload_ids":
                        pending_uploads[:10],
                    "status":"failed",
                    "error":
                        str(exc)[:500],
                },
            )

            raise

    use_fisheries = _is_fisheries_prompt(prompt)
    use_ocean = _needs_ocean_mcp(prompt)
    mcp_error = None
    turn_error = ""
    export_tool_completed=False
    export_tool_results=[]
    try:
        hf_task = None
        if use_fisheries:
            yield out("status",{"text":"正在读取 Hugging Face 渔业数据…","stage":"source","step":2,"total_steps":5})
            hf_task = asyncio.create_task(build_hf_fisheries_context(prompt))

        # Marine MCP is loaded by CodeWhale from DEEPSEEK_MCP_CONFIG when the
        # runtime process starts.  Do NOT bootstrap it by asking the model to
        # call start_mcp_server inside every user thread: that creates a long
        # blocking turn and can make the browser/HF proxy drop the SSE stream.
        # A normal Ocean request goes straight to the real user turn; if the
        # model needs Ocean data it can call the already-registered mcp_marine_*
        # tools directly.
        if use_ocean:
            yield out("status",{"text":"Ocean 数据工具已就绪,正在处理请求…","stage":"source","step":2,"total_steps":5})

        grounded_prompt = prompt

        if processing_context:
            grounded_prompt += (
                "\n\n" + processing_context
            )
        elif upload_context:
            grounded_prompt += (
                "\n\n" + upload_context
            )
        if hf_task is not None:
            try:
                hf_context = await hf_task
                grounded_prompt = grounded_prompt + "\n\n" + hf_context

                if usage_user_id:
                    asyncio.create_task(
                        safe_memory_event(
                            usage_user_id,
                            "fisheries_query",
                            {
                                "thread_id":tid,
                                "source":"huggingface-fisheries",
                                "status":"completed",
                                "prompt_chars":len(prompt.strip()),
                            },
                        )
                    )
            except Exception as exc:
                grounded_prompt = (
                    prompt
                    + "\n\n[HF_FISHERIES_LIVE_CONTEXT_ERROR]\n"
                    + str(exc)
                    + "\n[/HF_FISHERIES_LIVE_CONTEXT_ERROR]"
                )

        try:
            det=await rjson(f"/v1/threads/{tid}")
            since=int(det.get("latest_seq") or 0)
            tr=await rjson(f"/v1/threads/{tid}/turns",method="POST",body={
                "prompt":grounded_prompt,
                "input_summary":prompt[:200],
                "model":MODEL,
                "mode":"agent",
                "allow_shell":False,
                "trust_mode":False,
                "auto_approve":False,
            })
        except Exception as exc:
            if not _is_codewhale_missing_thread_error(exc):
                raise
            history_text=_format_recent_thread_history(tid)
            if history_text:
                grounded_prompt=(
                    "[PRIOR_CONVERSATION_HISTORY]\n"
                    + history_text
                    + "\n[/PRIOR_CONVERSATION_HISTORY]\n\n"
                    + "[CONVERSATION_RECOVERY_INSTRUCTION]\n"
                    + "用户在本次对话中已经描述过下载/查询要求。上面是同一用户的完整对话记录;"
                    + "请把最新用户消息当作该对话的后续补充或修改继续处理,"
                    + "不要要求用户重新提交完整需求。\n"
                    + "[/CONVERSATION_RECOVERY_INSTRUCTION]\n\n"
                    + grounded_prompt
                )
            old_tid=tid
            tid=await recover_codewhale_thread(old_tid)
            log.warning(
                "CodeWhale thread expired and was rebuilt: "
                "old=%s new=%s",
                old_tid,
                tid,
            )
            yield out(
                "status",
                {
                    "text":
                        "检测到原对话会话已过期,正在自动保留上下文并恢复…",
                    "stage":"source",
                    "step":1,
                    "total_steps":5,
                },
            )
            yield out(
                "thread_recovered",
                {
                    "thread_id":tid,
                    "old_thread_id":old_tid,
                },
            )
            det=await rjson(f"/v1/threads/{tid}")
            since=int(det.get("latest_seq") or 0)
            tr=await rjson(f"/v1/threads/{tid}/turns",method="POST",body={
                "prompt":grounded_prompt,
                "input_summary":prompt[:200],
                "model":MODEL,
                "mode":"agent",
                "allow_shell":False,
                "trust_mode":False,
                "auto_approve":False,
            })
        turn=((tr or {}).get("turn") or {}).get("id")
        answer=""
        yield out("status",{"text":"Codex 正在处理…","stage":"compose","step":4,"total_steps":5})

        async for rec in events(tid,since):
            if turn and rec.get("turn_id") and rec["turn_id"]!=turn:
                continue

            e=rec.get("event")
            p=pl(rec)

            if e=="item.started":
                tool=_tool_name_from_payload(p)

                if tool.startswith("mcp_marine_"):
                    yield out("status",{"text":"正在查询学校 Ocean 数据服务器…","stage":"query","step":3,"total_steps":5})

                    if usage_user_id:
                        asyncio.create_task(
                            safe_memory_event(
                                usage_user_id,
                                _marine_event_type(tool),
                                {
                                    "thread_id":tid,
                                    "tool":tool,
                                    "operation":_marine_event_type(tool),
                                    "source":_data_source_from_prompt(prompt),
                                    "status":"started",
                                    "prompt_chars":len(prompt.strip()),
                                },
                            )
                        )

            if e=="item.completed":
                item=p.get("item") or {}
                summary=str(item.get("summary") or "")
                completed_tool=_tool_name_from_payload(p)
                try:
                    completed_raw=json.dumps(p,ensure_ascii=False,default=str)
                except Exception:
                    completed_raw=str(p)

                if (
                    completed_tool.endswith("_export")
                    or "mcp_marine_marine_export" in completed_raw
                ):
                    export_tool_completed=True
                    export_tool_results.append(completed_raw)

                if "MCP server 'marine' connected" in summary or "mcp_marine_" in summary:
                    marine_threads.add(tid)
                # Some Runtime versions can complete an agent_message item without
                # delivering a delta to this bridge. Recover the materialized final text.
                recovered=_agent_text_from_payload(p)
                if recovered and not answer:
                    answer=recovered

            if e=="item.delta" and p.get("kind")=="agent_message":
                d=p.get("delta") or ""
                if d:
                    answer+=d

            if e in {"item.failed","item.interrupted"}:
                err=_error_from_payload(p)
                if err:
                    turn_error=err

            if e=="approval.required":
                aid=p.get("approval_id") or p.get("id")
                tool=(
                    p.get("tool_name")
                    or ((p.get("tool") or {}).get("name") if isinstance(p.get("tool"),dict) else p.get("tool"))
                    or ((p.get("item") or {}).get("tool_name") if isinstance(p.get("item"),dict) else "")
                    or ""
                )
                if aid:
                    if tool.startswith("mcp_marine_"):
                        await approve(aid,"allow")
                    elif tool=="start_mcp_server":
                        # Static MCP config is authoritative.  Never start another
                        # MCP server dynamically inside an end-user thread.
                        await approve(aid,"deny")
                        yield out("status",{"text":"已阻止重复启动 Ocean MCP"})
                    else:
                        await approve(aid,"deny")
                        yield out("status",{"text":"已保持安全数据访问模式"})

            if e=="turn.lifecycle":
                st=_turn_status(p)
                if st in {"failed","canceled","interrupted"}:
                    detail=_error_from_payload(p) or turn_error or f"Turn {st}"
                    raise RuntimeError(detail)

            if e=="turn.completed":
                st=_turn_status(p)
                if st in {"failed","canceled","interrupted"}:
                    detail=_error_from_payload(p) or turn_error or f"Turn {st}"
                    raise RuntimeError(detail)
                if not answer.strip():
                    detail=_error_from_payload(p) or turn_error
                    if detail:
                        raise RuntimeError(detail)
                    raise RuntimeError(
                        "Codex 回合已结束,但 CodeWhale 没有产生 assistant 文本。"
                    )
                final_answer=_sanitize_final_answer(answer)
                if not final_answer:
                    raise RuntimeError("模型返回内容在输出清理后为空。")

                export_error=_ocean_export_execution_error(
                    prompt,
                    export_tool_completed=export_tool_completed,
                    tool_result_text="\n".join(export_tool_results),
                    final_answer=final_answer,
                )
                if export_error:
                    raise RuntimeError(export_error)

                if usage_user_id:
                    asyncio.create_task(
                        record_generated_download_assets(
                            usage_user_id,
                            tid,
                            prompt,
                            final_answer,
                        )
                    )

                last_llm_error=None
                yield out("token",{"text":final_answer})
                yield out("done",{"text":final_answer})
                return

        raise RuntimeError(turn_error or "Runtime stream ended early")
    except Exception as exc:
        last_llm_error=str(exc)
        log.exception(
            "chat failed: thread=%s model=%s ocean=%s fisheries=%s",
            tid, MODEL, use_ocean, use_fisheries,
        )
        yield out("error",{"text":_public_error(str(exc)),"stage":"chat"})


async def harness_stream_chat(tid,prompt):
    global last_llm_error

    usage_user_id=thread_user_ids.get(tid,"")
    pending_uploads=thread_upload_ids.pop(tid,[])

    upload_context=""
    processing_context=""

    if usage_user_id and pending_uploads:
        upload_context=await build_user_upload_context(
            usage_user_id,
            pending_uploads,
        )

        asyncio.create_task(
            safe_memory_event(
                usage_user_id,
                "attachment_used",
                {
                    "thread_id":tid,
                    "upload_ids":pending_uploads[:10],
                    "count":len(pending_uploads[:10]),
                    "runtime":"codex-harness",
                },
            )
        )

    if (
        usage_user_id
        and pending_uploads
        and _quality_check_requested(prompt)
    ):
        task_id="proc_"+secrets.token_hex(8)

        await safe_memory_event(
            usage_user_id,
            "processing_started",
            {
                "task_id":task_id,
                "thread_id":tid,
                "operation":"quality_check",
                "upload_ids":pending_uploads[:10],
                "status":"running",
                "runtime":"codex-harness",
            },
        )

        yield out(
            "status",
            {
                "text":
                    "正在使用本地 Python 检查数据…"
            },
        )

        try:
            processing_result=await asyncio.to_thread(
                _run_upload_quality_checks,
                usage_user_id,
                pending_uploads,
            )

            record=_compact_processing_record(
                processing_result
            )

            await safe_memory_event(
                usage_user_id,
                "processing_completed",
                {
                    "task_id":task_id,
                    "thread_id":tid,
                    "operation":"quality_check",
                    "upload_ids":pending_uploads[:10],
                    "status":"completed",
                    "runtime":"codex-harness",
                    "result":record,
                },
            )

            processing_context=(
                "[USER_DATA_PROCESSING_RESULT]\n"
                "These values were computed locally "
                "with Python from the current user's "
                "uploaded file. Use them as the "
                "authoritative result. Do not guess "
                "or recompute them mentally. Explain "
                "the result clearly in Chinese.\n"
                + json.dumps(
                    record,
                    ensure_ascii=False,
                    default=str,
                )
                + "\n[/USER_DATA_PROCESSING_RESULT]"
            )

            yield out(
                "status",
                {
                    "text":
                        "数据计算完成,Codex Harness 正在整理结果…"
                },
            )

        except Exception as exc:
            await safe_memory_event(
                usage_user_id,
                "processing_failed",
                {
                    "task_id":task_id,
                    "thread_id":tid,
                    "operation":"quality_check",
                    "upload_ids":pending_uploads[:10],
                    "status":"failed",
                    "runtime":"codex-harness",
                    "error":str(exc)[:500],
                },
            )
            raise

    app_context=thread_system_prompts.get(
        tid,
        USER_SYSTEM,
    )

    harness_prompt=(
        "[APPLICATION_CONTEXT]\n"
        + app_context
        + "\n[/APPLICATION_CONTEXT]\n\n"
        + "[CURRENT_USER_MESSAGE]\n"
        + prompt
        + "\n[/CURRENT_USER_MESSAGE]"
    )

    if processing_context:
        harness_prompt += (
            "\n\n" + processing_context
        )
    elif upload_context:
        harness_prompt += (
            "\n\n" + upload_context
        )

    yield out(
        "status",
        {
            "text":
                f"Codex Harness · {HARNESS_MODEL} 正在处理…"
        },
    )

    try:
        task=asyncio.create_task(
            asyncio.to_thread(
                dsh.run,
                harness_prompt,
                session_id=tid,
            )
        )

        while not task.done():
            try:
                await asyncio.wait_for(
                    asyncio.shield(task),
                    timeout=8,
                )
            except asyncio.TimeoutError:
                yield ": keepalive\n\n"

        result=await task

        final_answer=_sanitize_final_answer(
            result.final_response
        )

        if not final_answer:
            raise RuntimeError(
                "Codex Harness 没有返回有效文本。"
            )

        log.info(
            "Codex Harness completed: "
            "thread=%s model=%s reason=%s uploads=%s",
            tid,
            HARNESS_MODEL,
            result.finish_reason,
            len(pending_uploads),
        )

        last_llm_error=None

        yield out(
            "token",
            {"text":final_answer},
        )

        yield out(
            "done",
            {
                "text":final_answer,
                "runtime":"codex-harness",
                "model":HARNESS_MODEL,
                "finish_reason":result.finish_reason,
            },
        )

    except Exception as exc:
        last_llm_error=str(exc)

        log.exception(
            "Codex Harness failed: "
            "thread=%s model=%s uploads=%s",
            tid,
            HARNESS_MODEL,
            len(pending_uploads),
        )

        yield out(
            "error",
            {
                "text":
                    "Codex Harness 调用失败:"
                    + str(exc)[:500],
                "stage":"harness",
            },
        )


DATA_REQUEST_TTL_SECONDS = 6 * 3600


def _is_runtime_info_prompt(prompt: str) -> bool:
    """Recognize the read-only deployment-info question locally."""
    text = str(prompt or "").strip().lower()
    return (
        ("供应商" in text or "provider" in text)
        and ("模型" in text or "model" in text)
        and ("运行时" in text or "runtime" in text)
        and not _needs_ocean_mcp(text)
        and not _is_fisheries_prompt(text)
    )


def _is_marine_health_prompt(prompt: str) -> bool:
    """Recognize a live Marine API status question without invoking the model."""
    text = str(prompt or "").strip().lower()
    health_terms = (
        "海洋数据服务器连接", "检查海洋数据服务器", "数据服务器连接",
        "三个数据域", "三个域是否在线", "ocean、tuna、squid",
        "ocean, tuna, squid", "marine health", "marine server health",
    )
    return any(term in text for term in health_terms)


def _is_fisheries_inventory_prompt(prompt: str) -> bool:
    """Recognize file-list requests that do not need an agent turn.

    A repository inventory is a read-only server operation.  Sending this
    request through the Codex CLI is both slow and, in headless deployments,
    vulnerable to the interactive-MCP approval limitation.  Keep actual file
    reads, analysis and exports on their normal guarded paths.
    """
    text = str(prompt or "").strip().lower()
    # Be explicit about the wording used by the school/HF comparison tests.
    # These requests mention storage provenance rather than a named source
    # such as GFW or FAO, but they are still metadata-only Squid/Tuna queries.
    explicit_inventory = (
        any(term in text for term in ("完整文件数", "总大小", "本地镜像", "回退读取"))
        and any(term in text for term in ("squid", "鱿鱼", "柔鱼", "tuna", "金枪鱼"))
    )
    if not _is_fisheries_prompt(text) and not explicit_inventory:
        return False
    inventory_terms = (
        "可用数据集", "当前可用", "文件来源", "文件清单", "列出",
        "有哪些文件", "入库", "目录", "inventory", "catalog",
    )
    if not any(term in text for term in inventory_terms) and not explicit_inventory:
        return False
    # “不读取文件内容” is explicitly a metadata-only request, rather than a
    # request to open a file.  Remove these negated phrases before testing for
    # real content work.
    metadata_text = text
    for phrase in (
        "不读取文件内容", "不读文件内容", "不读取内容", "不读内容",
        "不读取文件", "不读文件", "无需读取", "不要读取",
    ):
        metadata_text = metadata_text.replace(phrase, "")
    content_terms = (
        "字段", "记录数", "缺失", "重复", "筛选", "汇总", "聚合",
        "导出", "下载", "实际读取", "读取csv", "读取 csv",
    )
    return not any(term in metadata_text for term in content_terms)


async def _direct_fisheries_inventory_answer(prompt: str) -> str:
    """Return a live fisheries file inventory without invoking the LLM/MCP."""
    text = str(prompt or "").lower()
    wants_tuna = any(term in text for term in ("tuna", "金枪鱼"))
    wants_squid = any(term in text for term in ("squid", "鱿鱼", "柔鱼"))
    domains = []
    if wants_squid or not wants_tuna:
        domains.append("squid")
    if wants_tuna:
        domains.append("tuna")

    repo_map = globals().get("HF_DATASET_REPOS") or {}
    tree_reader = globals().get("hf_live_tree")
    files_only = globals().get("_hf_live_files")
    human_bytes = globals().get("_human_bytes")
    local_tree = globals().get("local_dataset_tree")
    if not callable(tree_reader) or not callable(files_only) or not callable(human_bytes):
        return "渔业数据清单服务尚未就绪。"

    source_terms = {
        "GFW": ("gfw", "global_fishing_watch", "global-fishing-watch"),
        "SPRFMO": ("sprfmo",),
        "NPFC": ("npfc",),
        "WCPFC": ("wcpfc",),
        "IATTC": ("iattc",),
        "ICCAT": ("iccat",),
        "IOTC": ("iotc",),
        "CCSBT": ("ccsbt",),
        "FAO": ("fao",),
        "Sea Around Us": ("sea_around", "sea-around", "sea around"),
        "RAM Legacy": ("ram",),
        "VIIRS": ("viirs",),
    }
    lines = []
    for domain in domains:
        repo = str(repo_map.get(domain) or "").strip()
        if not repo:
            lines.append(f"- {domain.title()}:未配置数据仓库。")
            continue
        try:
            raw_items = await tree_reader(repo)
            files = files_only(raw_items)
        except Exception as exc:
            lines.append(f"- {domain.title()}:清单读取失败({str(exc)[:220]})。")
            continue

        total = sum(int(item.get("size_bytes") or 0) for item in files)
        is_local = False
        try:
            is_local = bool(callable(local_tree) and local_tree(repo))
        except Exception:
            pass
        origin = "学校服务器本地镜像" if is_local else "Hugging Face main 分支"
        lines.extend([
            f"## {domain.title()} 数据清单",
            f"- 仓库:`{repo}`",
            f"- 来源:{origin}",
            f"- 可用文件:{len(files)} 个,约 {human_bytes(total)}",
        ])

        groups = []
        for name, terms in source_terms.items():
            matched = [
                item for item in files
                if any(term in str(item.get("path") or "").lower() for term in terms)
            ]
            if matched:
                groups.append(
                    f"- {name}{len(matched)} 个文件,约 "
                    f"{human_bytes(sum(int(item.get('size_bytes') or 0) for item in matched))}"
                )
        if groups:
            lines.append("- 按来源:")
            lines.extend(groups)
        else:
            lines.append("- 按来源:当前文件路径未匹配到可识别来源标签。")

        preview = files[:12]
        if preview:
            lines.append("- 文件示例:")
            lines.extend(
                f"  - `{item.get('path')}`({human_bytes(item.get('size_bytes') or 0)})"
                for item in preview
            )
        lines.append("- 本次仅读取文件清单元数据,未打开或分析文件内容。")

    return "\n".join(lines) if lines else "未找到可查询的渔业数据域。"


async def _direct_marine_health_answer():
    """Query the configured Marine API directly for a deterministic status answer."""
    httpx_module = globals().get("httpx")
    if httpx_module is None:
        try:
            import httpx as httpx_module
        except Exception as exc:
            return f"海洋数据服务器连接检查失败:HTTP 客户端不可用({exc})"
    base = str(globals().get("MARINE_API_URL") or "").rstrip("/")
    if not base:
        return "海洋数据服务器未配置 MARINE_API_URL。"

    candidates = [f"{base}/health"]
    # HF may expose the school API below the app's /marine reverse-proxy path.
    if not base.endswith("/marine"):
        candidates.append(f"{base}/marine/health")

    last_error = ""
    async with httpx_module.AsyncClient(timeout=12.0, follow_redirects=True) as client:
        for url in candidates:
            try:
                response = await client.get(url)
                if response.is_success:
                    payload = response.json()
                    domains = payload.get("domains") if isinstance(payload, dict) else None
                    labels = {
                        "ocean": "在线",
                        "tuna": "在线",
                        "squid": "在线",
                    }
                    if isinstance(domains, dict):
                        for key in labels:
                            value = domains.get(key)
                            if isinstance(value, dict):
                                value = value.get("status") or value.get("online")
                            if value in (False, "offline", "未连接", "不可用"):
                                labels[key] = "离线/不可用"
                    elif isinstance(domains, list):
                        for key in labels:
                            if key not in {str(item).lower() for item in domains}:
                                labels[key] = "未在健康响应中列出"
                    return (
                        "海洋数据服务器连接正常。\n"
                        f"- Ocean:{labels['ocean']}\n"
                        f"- Tuna:{labels['tuna']}\n"
                        f"- Squid:{labels['squid']}"
                    )
                last_error = f"HTTP {response.status_code}"
            except Exception as exc:
                last_error = str(exc)
    return f"海洋数据服务器连接检查失败:{last_error[:240]}"


def _context_spec_has_data(spec) -> bool:
    if not isinstance(spec, dict):
        return False
    return any(
        spec.get(key)
        for key in (
            "domain", "dataset", "variables", "region",
            "depth", "date", "format",
        )
    )


async def dispatch_chat_stream(tid,prompt):
    blocked = server_management_refusal(prompt)
    if blocked:
        yield out("token", {"text": blocked})
        yield out(
            "done",
            {
                "text": blocked,
                "runtime": "server-management-firewall",
                "model": "none",
                "finish_reason": "blocked",
            },
        )
        return

    # Very short conversational greetings do not need a model turn or an MCP
    # bootstrap.  Keep data requests on the official Codex Harness path, but
    # answer these deterministic prompts immediately so a simple "你好" does
    # not pay the full CLI/network startup cost.
    if _is_runtime_info_prompt(prompt):
        provider = str(globals().get("CODEX_PROVIDER_NAME") or os.environ.get("CODEX_PROVIDER", "ZAI")).upper()
        model = str(globals().get("HARNESS_MODEL") or os.environ.get("CODEX_MODEL", "glm-5.3"))
        runtime = "官方 Codex CLI Harness(native)"
        answer = f"- 供应商:{provider}\n- 模型名称:{model}\n- 运行时:{runtime}"
        yield out("token", {"text": answer})
        yield out("done", {"text": answer, "runtime": "local-fastpath", "model": model, "finish_reason": "completed"})
        return

    if _is_marine_health_prompt(prompt):
        answer = await _direct_marine_health_answer()
        yield out("token", {"text": answer})
        yield out("done", {"text": answer, "runtime": "direct-marine-health", "model": "none", "finish_reason": "completed"})
        return

    if _is_fisheries_inventory_prompt(prompt):
        # Never let a blocked HF connection or a malformed inventory response
        # fall through to the 900-second Codex Harness timeout.  Metadata-only
        # inventory is deterministic and must always terminate promptly.
        log.info("direct fisheries inventory fast path: prompt_chars=%s", len(str(prompt or "")))
        try:
            answer = await asyncio.wait_for(
                _direct_fisheries_inventory_answer(prompt),
                timeout=float(os.environ.get("FISHERIES_INVENTORY_TIMEOUT", "45")),
            )
        except asyncio.TimeoutError:
            answer = (
                "学校服务器的 Squid 清单查询超过 45 秒,已停止等待模型调用。"
                "请稍后重试;本次没有读取文件内容,也没有生成文件。"
            )
        yield out("token", {"text": answer})
        yield out(
            "done",
            {
                "text": answer,
                "runtime": "direct-fisheries-inventory",
                "model": "none",
                "finish_reason": "completed",
            },
        )
        return

    greeting = str(prompt or "").strip().lower()
    # Keep short conversational greetings local.  Variants such as
    # "你好不好" must not start the full Codex/MCP network path.
    greeting = greeting.replace("!", "!").replace("?", "?")
    greeting_replies = {
        "你好": "你好!有什么可以帮你的吗?",
        "你好!": "你好!有什么可以帮你的吗?",
        "你好?": "你好!有什么可以帮你的吗?",
        "你好不好": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
        "你好吗": "你好!我很好,谢谢关心。有什么可以帮你的吗?",
        "你好啊": "你好!有什么可以帮你的吗?",
        "你好呀": "你好!有什么可以帮你的吗?",
        "您好": "您好!有什么可以帮你的吗?",
        "您好!": "您好!有什么可以帮你的吗?",
        "您好?": "您好!有什么可以帮你的吗?",
        "嗨": "你好!有什么可以帮你的吗?",
        "hello": "Hello!有什么可以帮你的吗?",
        "hi": "你好!有什么可以帮你的吗?",
        "在吗": "在的,请告诉我你的海洋数据需求。",
    }
    if greeting in greeting_replies:
        answer = greeting_replies[greeting]
        yield out("token", {"text": answer})
        yield out(
            "done",
            {
                "text": answer,
                "runtime": "local-fastpath",
                "model": "none",
                "finish_reason": "completed",
            },
        )
        return

    now=time.time()
    pending=thread_last_data_requests.get(tid) or {}
    if pending and now-float(pending.get("ts") or 0)>DATA_REQUEST_TTL_SECONDS:
        thread_last_data_requests.pop(tid, None)
        pending={}

    history_spec=build_request_spec_from_messages(
        thread_recent_history.get(tid) or []
    )
    context_spec=thread_compressed_contexts.get(tid) or {}
    if not _context_spec_has_data(context_spec):
        context_spec=history_spec
    if not _context_spec_has_data(context_spec) and pending.get("spec"):
        context_spec=pending.get("spec")

    explicit_data=(
        _needs_ocean_mcp(prompt)
        or _is_fisheries_prompt(prompt)
    )
    is_confirmation=_is_confirmation_prompt(prompt)
    is_amendment=bool(
        (not is_confirmation)
        and pending.get("prompt")
        and _is_data_request_followup(prompt)
    )
    resumed=bool(
        pending.get("prompt")
        and (is_confirmation or is_amendment)
    )

    # Remember every data request and each short amendment that depends on it.
    # This lets follow-ups such as “补充区域…”/“把格式改成 CSV” stay in the
    # same data runtime and be merged with the original parameters instead of
    # being treated as brand-new unrelated messages.
    if is_amendment:
        merged_spec=merge_request_spec(
            context_spec,
            prompt,
            revision=True,
        )
        thread_compressed_contexts[tid]=merged_spec
        thread_last_data_requests[tid]={
            "prompt":render_request_spec(merged_spec),
            "ts":now,
            "spec":merged_spec,
        }
    elif explicit_data and not resumed:
        parsed_spec=extract_request_spec(prompt)
        if _context_spec_has_data(parsed_spec):
            merged_spec=merge_request_spec(
                None,
                prompt,
                revision=False,
            )
            thread_compressed_contexts[tid]=merged_spec
        else:
            merged_spec={"raw_request":str(prompt or "")[:1600]}
            thread_compressed_contexts[tid]=merged_spec
        thread_last_data_requests[tid]={
            "prompt":render_request_spec(merged_spec) or prompt,
            "ts":now,
            "spec":merged_spec,
        }

    # Fast-path multi-day Ocean exports into a persistent batch job instead of
    # keeping one Agent turn open for dozens of serial tool calls.
    batch_spec=parse_ocean_batch_request(prompt)
    if batch_spec:
        if not resumed:
            batch_spec_context=thread_compressed_contexts.get(tid) or {}
            thread_last_data_requests[tid]={
                "prompt":(
                    render_request_spec(batch_spec_context)
                    or prompt
                ),
                "ts":now,
                "spec":batch_spec_context,
            }
        uid=thread_user_ids.get(tid,"")
        job=await OCEAN_BATCH_MANAGER.create(uid,prompt,batch_spec,tid)
        yield out("status",{
            "text":f"已拆分为 {job['total']} 个单日子任务,后台并行执行中…",
            "stage":"query","step":3,"total_steps":5,
        })
        yield out("batch_job",job)
        variables="、".join(batch_spec.get("variables") or [])
        bbox=batch_spec.get("bbox") or {}
        region_note=(
            f"区域:{batch_spec.get('region_name')} "
            f"({bbox.get('lon_min')}°E–{bbox.get('lon_max')}°E, "
            f"{bbox.get('lat_min')}°N–{bbox.get('lat_max')}°N)。"
            if batch_spec.get("region_name") else ""
        )
        text=(
            f"已创建 Ocean 批量导出任务,共 {job['total']} 个单日子任务。"
            f"范围 {batch_spec['start_date']}{batch_spec['end_date']},变量:{variables}。"
            + region_note +
            "学校 Ocean 接口只接收单日 YYYY-MM-DD;平台已自动把月份/日期范围拆成单日并行执行,"
            "不会再要求您补充某一天。任务卡会实时显示完成、失败、执行中和等待数量。"
        )
        yield out("done",{"text":text,"batch_job_id":job["job_id"]})
        return

    has_upload = bool(thread_upload_ids.get(tid))
    compact_spec_text=render_request_spec(
        thread_compressed_contexts.get(tid) or context_spec
    )
    routed_prompt=prompt
    if is_confirmation and resumed:
        thread_last_data_requests[tid]={
            "prompt":compact_spec_text or str(pending["prompt"]),
            "ts":now,
            "spec":thread_compressed_contexts.get(tid) or context_spec,
        }
        routed_prompt=(
            "请下载或继续执行以下已确认的数据请求,保留全部参数:\n\n"
            + (compact_spec_text or str(pending["prompt"]))
            + "\n\n[USER_CONFIRMATION]\n"
            + "用户刚刚回复确认。请立即继续执行上一项数据查询或导出,"
            + "沿用已经给出的日期、区域、变量和数据源,不要再次询问确认。\n"
            + "[/USER_CONFIRMATION]"
        )
    elif is_amendment:
        routed_prompt=(
            "请下载或继续执行以下数据请求,保留全部参数:\n\n"
            + str(
                thread_last_data_requests[tid].get("prompt")
                or pending["prompt"]
                or ""
            )
            + "\n\n[AMENDMENT_DIRECTIVE]\n"
            + "用户正在补充或修改上一项数据下载/查询请求。"
            + "请把“最近补充/修改”作为本次唯一变更,其余条件继续沿用卡片中的参数;"
            + "按最新要求立即执行,不要要求用户重新提交完整需求。\n"
            + "[/AMENDMENT_DIRECTIVE]"
        )
    routed_prompt=_apply_ocean_export_defaults(routed_prompt)
    if _is_parse_only_request(prompt):
        routed_prompt += (
            "\n\n[READ_ONLY_PARAMETER_PARSE]\n"
            "这是只解析参数的请求。禁止调用任何 Marine/MCP 数据工具,禁止查询、"
            "导出、下载或创建任务。只返回从用户原话提取出的参数、缺失参数和校验结果。"
            "[/READ_ONLY_PARAMETER_PARSE]"
        )

    # v4.3.2: never send a Harness thread to CodeWhale.
    # When dsh is configured, CodeWhale is not a second required runtime.
    # This fixes ordinary chat and prevents a data-related keyword from
    # switching the user back to an unavailable localhost service.
    keep_codewhale = dsh is None

    if keep_codewhale:
        async for chunk in stream_chat(tid, routed_prompt):
            yield chunk
        return

    async for chunk in harness_stream_chat(tid, routed_prompt):
        yield chunk


def init_chat_runtime(context):
    _inject(context)
    return stream_chat, harness_stream_chat, dispatch_chat_stream