-
Notifications
You must be signed in to change notification settings - Fork 292
Expand file tree
/
Copy pathtask_executor.py
More file actions
2642 lines (2428 loc) · 128 KB
/
Copy pathtask_executor.py
File metadata and controls
2642 lines (2428 loc) · 128 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
# -*- coding: utf-8 -*-
# Copyright 2025-2026 Project N.E.K.O. Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
DirectTaskExecutor: merges the Analyzer + Planner roles
Evaluates ComputerUse / BrowserUse / UserPlugin feasibility in parallel
"""
import json
import os
import re
import hashlib
import asyncio
import time
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable, Awaitable
from dataclasses import dataclass
from datetime import datetime, timezone
import uuid
from utils.llm_client import openai_retry_error_types
import httpx
from config import (
USER_PLUGIN_SERVER_PORT,
AGENT_HISTORY_TURNS,
AGENT_RECENT_CTX_PER_ITEM_TOKENS,
AGENT_RECENT_CTX_TOTAL_TOKENS,
AGENT_PLUGIN_DESC_BM25_THRESHOLD,
AGENT_PLUGIN_SHORTDESC_MAX_TOKENS,
AGENT_PLUGIN_COARSE_MAX_TOKENS,
AGENT_UNIFIED_ASSESS_MAX_TOKENS,
AGENT_PLUGIN_FULL_MAX_TOKENS,
AGENT_EXTERNAL_GATE_ENABLED,
AGENT_EXTERNAL_GATE_THRESHOLD,
TASK_DETAIL_MAX_TOKENS,
)
from utils.llm_client import (
create_chat_llm,
ChatOpenAI,
set_active_character,
reset_active_character,
)
from config.prompts.prompts_agent import (
UNIFIED_CHANNEL_SYSTEM_PROMPT,
CHANNEL_DESC_QWENPAW,
CHANNEL_DESC_OPENFANG,
CHANNEL_DESC_BROWSER_USE,
CHANNEL_DESC_COMPUTER_USE,
USER_PLUGIN_SYSTEM_PROMPT,
USER_PLUGIN_COARSE_SCREEN_PROMPT,
)
from config.prompts.prompts_sys import _loc
from utils.file_utils import atomic_write_json, robust_json_loads
from plugin.settings import PLUGIN_EXECUTION_TIMEOUT
from utils.config_manager import get_config_manager
from utils.logger_config import get_module_logger
from utils.token_tracker import set_call_type
from .computer_use import ComputerUseAdapter
from .browser_use_adapter import BrowserUseAdapter
from .openclaw_adapter import OpenClawAdapter
from .openfang_adapter import OpenFangAdapter
from .plugin_filter import (
stage1_filter,
annotate_keyword_hits,
_match_keywords,
)
logger = get_module_logger(__name__, "Agent")
_TIMEOUT_UNSET = object()
def _normalize_timeout_value(value: Any) -> float | None | object:
"""Normalize timeout values.
Returns:
`_TIMEOUT_UNSET` when the value is missing/invalid,
`None` for explicit no-timeout (`None` or `<= 0`),
or a positive float timeout.
"""
if value is _TIMEOUT_UNSET:
return _TIMEOUT_UNSET
if value is None:
return None
try:
timeout_value = float(value)
except (TypeError, ValueError):
return _TIMEOUT_UNSET
return timeout_value if timeout_value > 0 else None
def _resolve_plugin_entry_timeout(meta: Optional[Dict[str, Any]], entry: Optional[str]) -> float | None:
default_timeout = PLUGIN_EXECUTION_TIMEOUT
if not isinstance(meta, dict):
return default_timeout
entries = meta.get("entries")
if not isinstance(entries, list):
return default_timeout
target_entry = entry or "run"
for item in entries:
if not isinstance(item, dict):
continue
if item.get("id") != target_entry:
continue
resolved = _normalize_timeout_value(item.get("timeout", _TIMEOUT_UNSET))
if resolved is not _TIMEOUT_UNSET:
return resolved
break
return default_timeout
def _resolve_ctx_entry_timeout(ctx_obj: Any, fallback_timeout: float | None) -> float | None:
if isinstance(ctx_obj, dict):
resolved = _normalize_timeout_value(ctx_obj.get("entry_timeout", _TIMEOUT_UNSET))
if resolved is not _TIMEOUT_UNSET:
return resolved
return fallback_timeout
def _compute_run_wait_timeout(entry_timeout: float | None) -> float | None:
if entry_timeout is None:
return None
return max(entry_timeout + 15.0, 315.0)
@dataclass
class TaskResult:
"""Task execution result"""
task_id: str
has_task: bool = False
task_description: str = ""
execution_method: str = "none" # "computer_use" | "browser_use" | "user_plugin" | "openclaw" | "openfang" | "none"
success: bool = False
result: Any = None
error: Optional[str] = None
tool_name: Optional[str] = None
tool_args: Optional[Dict] = None
entry_id: Optional[str] = None
reason: str = ""
latest_user_request: str = ""
normalized_intent: str = ""
recent_context: Optional[List[Dict[str, str]]] = None
@dataclass
class ComputerUseDecision:
"""ComputerUse feasibility assessment result"""
has_task: bool = False
can_execute: bool = False
task_description: str = ""
reason: str = ""
@dataclass
class BrowserUseDecision:
"""BrowserUse feasibility assessment result"""
has_task: bool = False
can_execute: bool = False
task_description: str = ""
reason: str = ""
@dataclass
class UserPluginDecision:
"""UserPlugin feasibility assessment result"""
has_task: bool = False
can_execute: bool = False
task_description: str = ""
plugin_id: Optional[str] = None
entry_id: Optional[str] = None
plugin_args: Optional[Dict] = None
reason: str = ""
@dataclass
class OpenFangDecision:
"""OpenFang multi-agent execution decision"""
has_task: bool = False
can_execute: bool = False
task_description: str = ""
suggested_tools: Optional[List[str]] = None
reason: str = ""
@dataclass
class OpenClawDecision:
"""OpenClaw standalone-agent execution decision"""
has_task: bool = False
can_execute: bool = False
task_description: str = ""
instruction: str = ""
reason: str = ""
@dataclass
class UnifiedChannelDecision:
"""Unified channel assessment result — each channel is a dict or None"""
qwenpaw: Optional[Dict[str, Any]] = None # {"can_execute": bool, "task_description": str, "reason": str}
openfang: Optional[Dict[str, Any]] = None
browser_use: Optional[Dict[str, Any]] = None
computer_use: Optional[Dict[str, Any]] = None
# 优先级:qwenpaw > openfang > browser_use > computer_use
_CHANNEL_PRIORITY = ["qwenpaw", "openfang", "browser_use", "computer_use"]
_CHANNEL_TO_METHOD = {
"qwenpaw": "openclaw",
"openfang": "openfang",
"browser_use": "browser_use",
"computer_use": "computer_use",
}
class DirectTaskExecutor:
"""
Direct task executor: evaluates BrowserUse / ComputerUse / UserPlugin feasibility in parallel and executes
"""
def __init__(self, computer_use: Optional[ComputerUseAdapter] = None, browser_use: Optional[BrowserUseAdapter] = None,
openclaw: Optional[OpenClawAdapter] = None,
openfang: Optional[OpenFangAdapter] = None):
self.computer_use = computer_use or ComputerUseAdapter()
self.browser_use = browser_use
self.openclaw = openclaw
self.openfang: Optional[OpenFangAdapter] = openfang
self._config_manager = get_config_manager()
self.plugin_list = []
self.user_plugin_enabled_default = False
self._external_plugin_provider: Optional[Callable[[bool], Awaitable[List[Dict[str, Any]]]]] = None
# ChatOpenAI instance cache: keyed by (api_key, base_url, model, temperature, max_completion_tokens)
self._cached_llms: dict[tuple, ChatOpenAI] = {}
self._cached_llm_config_key: tuple = () # tracks (api_key, base_url, model) to detect config changes
self._cleanup_tasks: set = set() # 持有关闭任务的强引用,防止 GC 回收
# plugin_id -> (description_key, generated_short_description)
# description_key = full description 的 hash(见 _desc_key):既精确反映完整
# description 的变化(截断只用于喂 LLM,不能当失效 key),又有界,避免超大
# description 撑爆内存/缓存文件。只有 LLM 生成的条目会落盘(见
# _persist_generated_short_descriptions);manifest 自带 short_description
# 的插件每次加载都能免费重新 prime,无需持久化。
self._short_desc_cache_filename = "plugin_short_desc_cache.json"
self._short_desc_cache: dict[str, tuple[str, str]] = self._load_short_desc_cache()
# plugin ids currently being generated in a background prewarm task —
# dedupes the per-analyze force_refresh so we don't pile up duplicate
# generation tasks. The tasks set holds strong refs to prevent GC.
self._short_desc_prewarm_inflight: set[str] = set()
self._short_desc_prewarm_tasks: set = set()
self._correction_memory_filename = "correction_memory.json"
self._search_term_allowlist = {"id", "os", "db", "ui", "ux", "qa"}
# 白名单 + alias 归一化,防止任意字符串被写进 correction_memory.json
# 并跨会话注入到路由 system prompt 里。未命中一律归一为空串,由调用方丢弃。
self._correction_tool_canonical = {
"computer_use": "computer_use",
"browser_use": "browser_use",
"openclaw": "openclaw",
"qwenpaw": "openclaw",
"openfang": "openfang",
"user_plugin": "user_plugin",
}
def _normalize_correction_tool_name(self, value: Any) -> str:
tool = str(value or "").strip().lower()
return self._correction_tool_canonical.get(tool, "")
async def _set_character_context_token(self, lanlan_name: Optional[str]):
"""Fetch master_name from the config manager and bind the active
character ``(master_name, lanlan_name)`` to the current async
context. The wrapped LLM clients (``utils.llm_client.ChatOpenAI``)
substitute ``{MASTER_NAME}`` / ``{LANLAN_NAME}`` placeholders that
come in via plugin-supplied prompt fragments before the wire send.
Returns a token; pass to ``reset_active_character`` in a ``finally``
block. Best-effort on failure — if config_manager can't yield a
master_name, the token still binds with an empty master so partial
substitution (lanlan only) still works and the leak check WARNING
is at most a no-op.
"""
master_name = ""
try:
cd = await self._config_manager.aget_character_data()
# aget_character_data returns a tuple; element 0 is master_name
if cd and len(cd) > 0 and isinstance(cd[0], str):
master_name = cd[0]
except Exception as exc:
logger.debug(
"[Agent] character-context fetch failed; placeholder substitution will be partial: %s: %s",
type(exc).__name__, exc,
)
return set_active_character(master_name, lanlan_name or "")
def set_plugin_list_provider(self, provider: Callable[[bool], Awaitable[List[Dict[str, Any]]]]):
"""Allow agent_server to inject a custom async provider for plugin discovery."""
self._external_plugin_provider = provider
@staticmethod
def _desc_key(desc: str) -> str:
"""Stable, bounded validity key for a plugin description. The cache hits
only while the *full* description is unchanged; hashing keeps the key
small (a plugin's raw description is uncapped)."""
return hashlib.sha256((desc or "").encode("utf-8")).hexdigest()
def _apply_cached_short_descriptions(self, plugins: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Apply manifest-provided or previously-generated short_description
onto each plugin dict. Pure manifest/cache read — NEVER calls the LLM,
so it is safe on the analyze hot path.
Returns the plugins that are still missing a short_description (and have
a description to summarize) — i.e. the candidates for background prewarm.
"""
missing: list[dict] = []
for p in plugins:
if not isinstance(p, dict):
continue
pid = p.get("id", "")
short = str(p.get("short_description", "") or "").strip()
desc = str(p.get("description", "") or "").strip()
if not short:
# Apply cached value if available and the (full) description
# hasn't changed. Key off the full description, not the truncated
# one used for the LLM prompt, so long-description plugins still hit.
cached = self._short_desc_cache.get(pid)
if cached and cached[0] == self._desc_key(desc):
p["short_description"] = cached[1]
continue
if desc:
missing.append(p)
elif pid:
# (a) manifest already carries short_description — use it as-is,
# zero LLM. Prime the cache so it survives a desc-unchanged refresh.
self._short_desc_cache[pid] = (self._desc_key(desc), short)
return missing
def _schedule_short_desc_prewarm(self, plugins: List[Dict[str, Any]]) -> None:
"""Apply cached/manifest short_descriptions onto ``plugins`` (hot-path
safe, zero LLM) and, for any plugin still missing one, schedule a
fire-and-forget background task to generate it at plugin-load time.
NEVER awaited on the analyze path: the current analyze safely falls back
to the full description for plugins whose short_description hasn't been
generated yet (see ``_stage1_llm_coarse_screen``). The generated value
lands in ``_short_desc_cache`` for subsequent analyze runs.
Deduped by plugin id via ``_short_desc_prewarm_inflight`` so the
per-analyze ``force_refresh`` doesn't pile up duplicate generation tasks.
"""
missing = self._apply_cached_short_descriptions(plugins)
if not missing:
return
# Lazy-init for instances built via object.__new__ (test fixtures bypass __init__).
inflight = getattr(self, "_short_desc_prewarm_inflight", None)
if inflight is None:
inflight = set()
self._short_desc_prewarm_inflight = inflight
if getattr(self, "_short_desc_prewarm_tasks", None) is None:
self._short_desc_prewarm_tasks = set()
pending = [
p for p in missing
if str(p.get("id", "")).strip() and str(p.get("id", "")) not in inflight
]
if not pending:
return
pids = {str(p.get("id", "")) for p in pending}
# Resolve the loop BEFORE constructing the coroutine: if there's no
# running event loop (sync context), bail out without leaving an
# un-awaited coroutine behind. analyze still falls back to full desc.
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
inflight |= pids
task = loop.create_task(self._prewarm_short_descriptions(pending, pids))
self._short_desc_prewarm_tasks.add(task)
task.add_done_callback(self._short_desc_prewarm_tasks.discard)
async def _prewarm_short_descriptions(
self, to_generate: List[Dict[str, Any]], pids: set[str],
) -> None:
"""Background LLM generation of short_description for plugins missing one
(best-effort, cached). Runs OFF the analyze hot path — scheduled by
``_schedule_short_desc_prewarm`` at plugin-load time. Newly generated
entries are persisted to disk so subsequent app restarts reuse them
(keyed by description, so a manifest change still invalidates)."""
generated: dict[str, tuple[str, str]] = {}
try:
logger.info("[Agent] Generating short_description for %d plugin(s)", len(to_generate))
llm = self._get_llm(temperature=0, max_completion_tokens=AGENT_PLUGIN_SHORTDESC_MAX_TOKENS)
for p in to_generate:
pid = p.get("id", "unknown")
try:
from config import PLUGIN_INPUT_DESC_MAX_TOKENS
from utils.tokenize import truncate_to_tokens
raw_desc = str(p.get("description", "") or "").strip()
# Plugin manifest 的 description 字段无 cap,恶意/超大
# plugin 可能塞 1MB 文本。先截到 PLUGIN_INPUT_DESC_MAX_TOKENS
# 再送入 short_description 生成 prompt。
desc = truncate_to_tokens(raw_desc, PLUGIN_INPUT_DESC_MAX_TOKENS)
messages = [
{"role": "system", "content": "You are an agentic automation assessment agent, generate a concise plugin summary under 200 tokens in English."},
{"role": "user", "content": f"Plugin: {pid}\nDescription: {desc}\n\nReturn ONLY the summary."},
]
resp = await llm.ainvoke(messages)
text = (resp.content or "").strip()
from utils.tokenize import count_tokens
if text and count_tokens(text) <= AGENT_PLUGIN_SHORTDESC_MAX_TOKENS:
p["short_description"] = text
# Key off the FULL description (truncation is prompt-only),
# so apply-time lookup hits even for long-description plugins.
desc_key = self._desc_key(raw_desc)
self._short_desc_cache[pid] = (desc_key, text)
if isinstance(pid, str) and pid:
generated[pid] = (desc_key, text)
# LLM 生成原文不写 logger
logger.debug("[Agent] Generated short_description for %s (len=%d chars)", pid, len(text))
print(f"[Agent] short_description {pid}: {text[:80]}")
except Exception as e:
# Don't cache failures — allow retry on next refresh
logger.debug("[Agent] Failed to generate short_description for %s: %s", pid, e)
except Exception as e:
logger.warning("[Agent] short_description generation batch failed: %s", e)
finally:
self._short_desc_prewarm_inflight -= pids
# 把本批生成的(贵的)条目落盘,下次启动直接复用、不再现生成。
# 这里刻意保留同步落盘(不改 await asyncio.to_thread),两个原因:
# 1) _persist_generated_short_descriptions 内部是「读盘—合并—写盘」,
# 全程没有锁;今天靠「整段同步、不让出事件循环」才保证两批并发
# prewarm 不互相覆盖(见该函数里 re-read 那行注释)。挪进线程后,
# 两批会各自在自己的 worker 线程里 load→merge→write 交错,先写的
# 那批条目会被后写的整份 payload 盖掉。
# 2) 这是 finally,而本协程绝大部分时间挂在 llm.ainvoke 上——事件循环
# 收尾时它正是会被 cancel 的 pending task。在取消路径的 finally 里
# await,落盘可能被直接跳过,白白丢掉花了 LLM 调用生成的条目。
# 代价可控:每批 prewarm 只写一次小 JSON,发生在插件加载期,不在
# analyze 热路径上。
self._persist_generated_short_descriptions(generated) # noqa: ASYNC_BLOCK — 无锁读-改-写 + 取消路径 finally,加 await 会引入互相覆盖/漏落盘
async def plugin_list_provider(self, force_refresh: bool = True) -> List[Dict[str, Any]]:
# return cached list when allowed
if self.plugin_list and not force_refresh:
return self.plugin_list
# try external provider first (e.g., injected by agent_server)
if self._external_plugin_provider is not None:
try:
plugins = await self._external_plugin_provider(force_refresh)
if isinstance(plugins, list):
self.plugin_list = plugins
# Apply cached/manifest short_descriptions synchronously
# (zero LLM) and prewarm any missing ones in the background —
# never generate on the analyze hot path.
self._schedule_short_desc_prewarm(self.plugin_list)
logger.info(f"[Agent] Loaded {len(self.plugin_list)} plugins via external provider")
return self.plugin_list
except Exception as e:
logger.warning(f"[Agent] external plugin_list_provider failed: {e}")
# fallback to built-in HTTP fetcher
if (self.plugin_list == []) or force_refresh:
try:
url = f"http://127.0.0.1:{USER_PLUGIN_SERVER_PORT}/plugins"
# increase timeout and avoid awaiting a non-awaitable .json()
timeout = httpx.Timeout(5.0, connect=2.0)
async with httpx.AsyncClient(timeout=timeout, proxy=None, trust_env=False) as _client:
resp = await _client.get(url)
try:
data = resp.json()
except Exception:
logger.warning("[Agent] Failed to parse plugins response as JSON")
data = {}
plugin_list = data.get("plugins", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
# only update cache when we obtained a non-empty list
if plugin_list:
self.plugin_list = plugin_list # 更新实例变量
# 同步应用缓存/manifest 的 short_description(零 LLM),
# 缺失的放后台预热,绝不在 analyze 热路径上现生成。
self._schedule_short_desc_prewarm(self.plugin_list)
except Exception as e:
logger.warning(f"[Agent] plugin_list_provider http fetch failed: {e}")
logger.info(f"[Agent] Loaded {len(self.plugin_list)} plugins: {[p.get('id', 'unknown') for p in self.plugin_list if isinstance(p, dict)]}")
return self.plugin_list
def _get_llm(
self,
*,
temperature: float = 0,
max_completion_tokens: int | None = None,
tier: str = "summary",
) -> ChatOpenAI:
"""Return a cached ChatOpenAI instance via create_chat_llm.
``tier`` selects the model tier (``summary`` / ``correction`` /
``emotion`` / ``vision`` …) — see ``ConfigManager.get_model_api_config``.
Instances are cached by (tier, api_key, base_url, model, temperature,
max_completion_tokens). When the provider config for the **summary**
tier changes (the de-facto default), all cached instances across all
tiers are closed and recreated, so callers don't need to flush per-tier.
"""
set_call_type("agent")
api_config = self._config_manager.get_model_api_config(tier)
# The cross-tier flush key tracks the summary tier's provider config
# (current behavior). Switching providers via the UI typically happens
# for the summary tier and the others share the same upstream; keying
# off summary keeps the original semantics.
watch_config = self._config_manager.get_model_api_config("summary")
watch_key = (
watch_config['api_key'],
watch_config['base_url'],
watch_config['model'],
watch_config.get('provider_type'),
)
if self._cached_llm_config_key != watch_key:
self._close_all_llms()
self._cached_llm_config_key = watch_key
instance_key = (
tier, api_config['api_key'], api_config['base_url'], api_config['model'],
api_config.get('provider_type'), temperature, max_completion_tokens,
)
if instance_key not in self._cached_llms:
llm = create_chat_llm(
model=api_config['model'],
base_url=api_config['base_url'],
api_key=api_config['api_key'],
temperature=temperature,
max_completion_tokens=max_completion_tokens,
max_retries=0,
timeout=120.0, # hang-guard for agent task LLM calls (large context + tool loops)
provider_type=api_config.get('provider_type'),
)
self._cached_llms[instance_key] = llm
logger.debug(
"[Agent] Created new ChatOpenAI (tier=%s, model=%s, base_url=%s, temp=%s, max_tokens=%s)",
tier, api_config['model'], api_config['base_url'], temperature, max_completion_tokens,
)
return self._cached_llms[instance_key]
def _close_all_llms(self) -> None:
"""Close all cached ChatOpenAI instances asynchronously."""
for llm in self._cached_llms.values():
self._close_llm_async(llm)
self._cached_llms.clear()
def _close_llm_async(self, llm: ChatOpenAI) -> None:
"""Asynchronously close a ChatOpenAI instance, preventing GC from dropping the task."""
async def _do_close():
try:
await llm.aclose()
except Exception as e:
logger.warning("[Agent] Failed to close old ChatOpenAI instance: %s", e)
finally:
self._cleanup_tasks.discard(task)
try:
task = asyncio.ensure_future(_do_close())
self._cleanup_tasks.add(task)
except RuntimeError:
logger.debug("[Agent] No running event loop, skipping async LLM close")
def _format_messages(self, messages: List[Dict[str, str]], *, proactive: bool = False) -> str:
"""Format conversation messages.
``proactive`` marks a self-initiated turn with no new user request: the
``LATEST_USER_REQUEST`` marker (which both the unified and plugin
assessors key on) is taken from lanlan's own latest utterance instead of
the stale prior user line, so the assessment is driven by the proactive
intent rather than the old request.
"""
def _extract_text(m: dict) -> str:
return str(m.get('text') or m.get('content') or '').strip()
def _extract_attachments(m: dict) -> list[dict]:
raw = m.get("attachments") or []
if not isinstance(raw, list):
return []
normalized = []
for item in raw:
if isinstance(item, str):
url = item.strip()
elif isinstance(item, dict):
url = str(item.get("url") or item.get("image_url") or "").strip()
else:
url = ""
if url:
normalized.append({"type": "image_url", "url": url})
return normalized
def _describe_user_message(text: str, attachments: list[dict]) -> str:
if text:
if attachments:
return f"{text} [Attached images: {len(attachments)}]"
return text
if attachments:
return f"[User attached {len(attachments)} image(s) without text]"
return ""
latest_user_text = ""
if proactive:
# Self-initiated turn → the actionable "request" is lanlan's own
# latest utterance, not the (stale) latest user line.
for m in reversed(messages[-AGENT_HISTORY_TURNS:]):
if str(m.get('role') or '').lower() == 'assistant':
latest_user_text = _extract_text(m)
if latest_user_text:
break
else:
for m in reversed(messages[-AGENT_HISTORY_TURNS:]):
if m.get('role') == 'user':
latest_user_text = _describe_user_message(_extract_text(m), _extract_attachments(m))
if latest_user_text:
break
lines = []
if latest_user_text:
lines.append(f"LATEST_USER_REQUEST: {latest_user_text}")
for m in messages[-AGENT_HISTORY_TURNS:]:
role = m.get('role', 'user')
text = _describe_user_message(_extract_text(m), _extract_attachments(m))
if text:
lines.append(f"{role}: {text}")
return "\n".join(lines)
def _extract_latest_user_payload(self, messages: List[Dict[str, Any]]) -> tuple[str, list[dict]]:
latest_text = ""
latest_attachments: list[dict] = []
for m in reversed(messages[-AGENT_HISTORY_TURNS:]):
if not isinstance(m, dict) or m.get("role") != "user":
continue
latest_text = str(m.get("text") or m.get("content") or "").strip()
raw_attachments = m.get("attachments") or []
if isinstance(raw_attachments, list):
for item in raw_attachments:
if isinstance(item, str):
url = item.strip()
elif isinstance(item, dict):
url = str(item.get("url") or item.get("image_url") or "").strip()
else:
url = ""
if url:
latest_attachments.append({
"type": "image_url",
"url": url,
})
if latest_text or latest_attachments:
break
if not latest_text and latest_attachments:
latest_text = "请分析用户提供的图片内容,并根据图片完成任务。"
return latest_text, latest_attachments
def _format_tools(self, capabilities: Dict[str, Dict[str, Any]]) -> str:
"""Format the tool list for LLM reference"""
if not capabilities:
return "No MCP tools available."
lines = []
for tool_name, info in capabilities.items():
desc = info.get('description', 'No description')
schema = info.get('input_schema', {})
params = schema.get('properties', {})
required = schema.get('required', [])
param_desc = []
for p_name, p_info in params.items():
p_type = p_info.get('type', 'any')
is_required = '(required)' if p_name in required else '(optional)'
param_desc.append(f" - {p_name}: {p_type} {is_required}")
lines.append(f"- {tool_name}: {desc}")
if param_desc:
lines.extend(param_desc)
return "\n".join(lines)
def _extract_latest_user_intent(self, conversation: str) -> str:
"""Extract the latest user request from formatted conversation text."""
user_intent = ""
conv_lines = conversation.splitlines()
for line in conv_lines:
if line.startswith("LATEST_USER_REQUEST:"):
user_intent = line[len("LATEST_USER_REQUEST:"):].strip()
break
if not user_intent:
for line in reversed(conv_lines):
if line.startswith("user:") or line.startswith("User:"):
user_intent = line[5:].strip()
break
return user_intent
@staticmethod
def _message_text(message: Dict[str, Any]) -> str:
return str(message.get("text") or message.get("content") or "").strip()
def _extract_recent_context(
self,
messages: List[Dict[str, Any]],
*,
limit: int = 4,
) -> List[Dict[str, str]]:
items: List[Dict[str, str]] = []
for message in messages:
role = str(message.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
text = self._message_text(message)
if not text:
continue
items.append({"role": role, "content": text})
return items[-limit:]
def _normalize_user_intent(
self,
latest_user_request: str,
recent_context: List[Dict[str, str]],
) -> str:
latest = re.sub(r"\s+", " ", (latest_user_request or "").strip())
if not latest:
return ""
normalized_latest = re.sub(r"[^\w\u4e00-\u9fff]+", " ", latest.lower()).strip()
vague_markers = (
"这个", "那个", "一下", "继续", "继续弄", "处理一下", "帮我弄一下",
"就这个", "刚才那个", "上一条", "发给他", "发给她", "发给它",
"打开它", "打开这个", "继续这个", "继续那个",
"this", "that", "this one", "that one", "it", "do it", "continue",
"go on", "keep going", "same one", "the same", "open it", "send it",
"上一個", "這個", "那個", "继续做", "接着做",
"これ", "それ", "これを", "それを", "続けて", "続ける", "やって", "やってね",
"이거", "저거", "이것", "그것", "계속", "계속해", "해줘", "그거 해줘",
)
user_turns = [
item.get("content", "").strip()
for item in recent_context
if item.get("role") == "user" and item.get("content", "").strip()
]
def _matches_vague_marker(marker: str) -> bool:
marker_norm = re.sub(r"[^\w\u4e00-\u9fff]+", " ", marker.lower()).strip()
if not marker_norm:
return False
if re.search(r"[a-z0-9]", marker_norm):
return re.search(rf"\b{re.escape(marker_norm)}\b", normalized_latest) is not None
return marker_norm in latest or marker_norm in normalized_latest
length_source = normalized_latest or latest
cjk_like_count = sum(
1
for ch in length_source
if (
"\u3040" <= ch <= "\u30ff" # Hiragana + Katakana
or "\u4e00" <= ch <= "\u9fff" # CJK Unified Ideographs
or "\uac00" <= ch <= "\ud7af" # Hangul Syllables
)
)
length_threshold = 3 if cjk_like_count * 2 >= len(length_source) else 6
latest_is_vague = len(length_source) <= length_threshold or any(
_matches_vague_marker(marker) for marker in vague_markers
)
if not latest_is_vague:
return latest
from utils.tokenize import truncate_to_tokens
context_candidates: List[str] = []
for text in user_turns[-3:]:
if text and text != latest:
context_candidates.append(text)
if context_candidates:
return truncate_to_tokens(" / ".join([*context_candidates[-2:], latest]), TASK_DETAIL_MAX_TOKENS)
return truncate_to_tokens(latest, TASK_DETAIL_MAX_TOKENS)
@staticmethod
def _sanitize_correction_text(text: str) -> str:
cleaned = str(text or "")
cleaned = cleaned.replace("\r", " ").replace("\n", " ")
patterns = [
(r"(?i)(password|passwd|pwd)\s*[:=]\s*\S+", r"\1=[REDACTED_PASSWORD]"),
(r"(?i)(password|passwd|pwd|密码|口令)\s*(?:is|为|是|=|:|:)\s*\S+", r"\1=[REDACTED_PASSWORD]"),
(r"(?i)authorization\s*:\s*bearer\s+\S+", "Authorization: Bearer [REDACTED_TOKEN]"),
(r"(?i)(token|api[_-]?key|access[_-]?token|refresh[_-]?token)\s*[:=]\s*\S+", r"\1=[REDACTED_TOKEN]"),
(
r"(?i)(token|api(?:[\s_-]?key)|access(?:[\s_-]?token)|refresh(?:[\s_-]?token)|令牌|密钥|秘钥)\s*(?:is|为|是|=|:|:)\s*\S+",
r"\1=[REDACTED_TOKEN]",
),
(r"(?i)\bsk-[a-z0-9_-]{10,}\b", "[REDACTED_TOKEN]"),
(r"(?i)(cookie)\s*[:=:]\s*\S+", r"\1=[REDACTED_COOKIE]"),
(r"(?i)(cookie)\s*(?:[:=:]|is|为|是)\s*\S+", r"\1=[REDACTED_COOKIE]"),
(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,}\b", "[REDACTED_EMAIL]"),
(
r"(?i)(\b(?:otp|pin|verification(?:\s+code)?|sms\s*code|one[-\s]?time(?:\s+password|\s+code)?|验证码|校验码|短信码|动态码)\b(?:\s*(?:is|为|是))?[\s::=#-]{0,6})\d{4,8}\b",
r"\1[REDACTED_OTP]",
),
(r"\b(?:\d{15}|\d{17}[0-9Xx])\b", "[REDACTED_ID]"),
(r"\b\d{15,19}\b", "[REDACTED_NUMBER]"),
(r"\b1[3-9]\d{9}\b", "[REDACTED_PHONE]"),
]
for pattern, replacement in patterns:
cleaned = re.sub(pattern, replacement, cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
from utils.tokenize import truncate_to_tokens
# Per-item cap on the redacted correction text (one role-message in the
# recent-context window). Group with `agent_server.py` callback summary —
# both are "longer reflective blurbs" the LLM will see standalone.
return truncate_to_tokens(cleaned, AGENT_RECENT_CTX_PER_ITEM_TOKENS)
def _sanitize_recent_context(self, recent_context: List[Dict[str, str]]) -> List[Dict[str, str]]:
from utils.tokenize import count_tokens
sanitized: List[Dict[str, str]] = []
total_tokens = 0
# Total budget across the assembled recent-context window — fits ~2-3
# per-item (400-token) entries plus headroom. Caller stops accumulating
# once we cross this; partial last item is dropped.
for item in reversed(recent_context[-4:]):
role = str(item.get("role") or "").strip().lower()
if role not in {"user", "assistant"}:
continue
content = self._sanitize_correction_text(item.get("content", ""))
if not content:
continue
total_tokens += count_tokens(content)
if total_tokens > AGENT_RECENT_CTX_TOTAL_TOKENS:
break
sanitized.append({"role": role, "content": content})
sanitized.reverse()
return sanitized
def _get_correction_memory_path(self) -> Path:
self._config_manager.ensure_config_directory()
return Path(self._config_manager.config_dir) / self._correction_memory_filename
def _load_correction_memory(self) -> Dict[str, Any]:
path = self._get_correction_memory_path()
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {"version": 1, "correction_events": []}
except Exception as exc:
logger.warning("[CorrectionMemory] Failed to load %s: %s", path, exc)
return {"version": 1, "correction_events": []}
if not isinstance(data, dict):
return {"version": 1, "correction_events": []}
events = data.get("correction_events")
if not isinstance(events, list):
data["correction_events"] = []
data.setdefault("version", 1)
return data
def _save_correction_memory(self, data: Dict[str, Any]) -> None:
path = self._get_correction_memory_path()
path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
# 走统一原子写(tmp + fsync + os.replace):补齐此前缺的 fsync,断电不留 0
# 字节文件;mkstemp 的 tmp 天生 0o600,不经过 umask 决定的可读窗口。
atomic_write_json(path, data, ensure_ascii=False, indent=2)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _get_short_desc_cache_path(self) -> Path:
self._config_manager.ensure_config_directory()
return Path(self._config_manager.config_dir) / self._short_desc_cache_filename
def _load_short_desc_cache(self) -> dict[str, tuple[str, str]]:
"""Load the on-disk short_description cache (LLM-generated entries only).
Returns ``{plugin_id: (description_key, short_description)}``.
``description_key`` is a hash of the full description (see ``_desc_key``):
at apply time we re-generate when the plugin's current description no
longer hashes to the stored key. Best-effort — a missing or corrupt file
just yields an empty cache.
"""
try:
path = self._get_short_desc_cache_path()
except Exception as exc:
logger.debug("[Agent] short_desc cache path unavailable: %s", exc)
return {}
try:
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
return {}
except Exception as exc:
logger.warning("[Agent] Failed to load short_desc cache %s: %s", path, exc)
return {}
entries = data.get("entries") if isinstance(data, dict) else None
if not isinstance(entries, dict):
return {}
cache: dict[str, tuple[str, str]] = {}
for pid, item in entries.items():
if not isinstance(pid, str) or not isinstance(item, dict):
continue
key = item.get("key")
short = item.get("short")
if isinstance(key, str) and isinstance(short, str) and short:
cache[pid] = (key, short)
return cache
def _persist_generated_short_descriptions(self, generated: dict[str, tuple[str, str]]) -> None:
"""Merge newly LLM-generated entries into the on-disk cache (atomic write).
Only generated entries are persisted; manifest-provided short_descriptions
are re-derived for free on every load and would only bloat the file (a
plugin's raw ``description`` is uncapped). Best-effort — a write failure
just means the next session regenerates."""
if not generated:
return
try:
path = self._get_short_desc_cache_path()
except Exception as exc:
logger.debug("[Agent] short_desc cache path unavailable, skip persist: %s", exc)
return
# Re-read so concurrent prewarm batches don't clobber each other's entries.
on_disk = self._load_short_desc_cache()
on_disk.update(generated)
payload = {
"version": 1,
"entries": {pid: {"key": k, "short": s} for pid, (k, s) in on_disk.items()},
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
# 统一原子写:tmp + fsync + os.replace,崩溃只丢 .tmp 不破坏原文件。
atomic_write_json(path, payload, ensure_ascii=False, indent=2)
try:
os.chmod(path, 0o600)
except OSError:
pass
except Exception as exc:
logger.warning("[Agent] Failed to persist short_desc cache %s: %s", path, exc)
def _is_allowed_search_term(self, term: str) -> bool:
if not term or term.isdigit():
return False
if len(term) == 2 and term.isascii() and term.isalpha() and term not in self._search_term_allowlist:
return False
return True
def _extract_search_terms(self, text: str) -> List[str]:
lowered = str(text or "").lower()
terms = re.findall(r"\w{2,}", lowered, flags=re.UNICODE)
seen: set[str] = set()
result: List[str] = []
for term in terms:
if not self._is_allowed_search_term(term):
continue
if term in seen:
continue
seen.add(term)
result.append(term)
return result[:24]
def _retrieve_relevant_corrections(
self,
latest_user_request: str,
*,
normalized_intent: str = "",
recent_context: Optional[List[Dict[str, str]]] = None,
limit: int = 3,
) -> List[Dict[str, Any]]:
memory = self._load_correction_memory()
events = memory.get("correction_events", [])
if not isinstance(events, list) or not events:
return []
query_blob_parts = [normalized_intent, latest_user_request]
for item in recent_context or []:
query_blob_parts.append(item.get("content", ""))
query_terms = self._extract_search_terms(" ".join(part for part in query_blob_parts if part))
if not query_terms:
return []
scored: List[tuple[int, datetime, Dict[str, Any]]] = []
for event in events:
if not isinstance(event, dict):
continue
event_context = " ".join(
[
str(event.get("normalized_intent", "")),
str(event.get("user_query", "")),
self._normalize_correction_tool_name(event.get("chosen_tool", "")),
self._normalize_correction_tool_name(event.get("correct_tool", "")),
" ".join(
str(item.get("content", ""))
for item in event.get("recent_context", [])
if isinstance(item, dict)
),
]
).lower()
score = 0