python-library-ai-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ai_agent/__init__.py +66 -0
- ai_agent/agent.py +122 -0
- ai_agent/app/__init__.py +10 -0
- ai_agent/app/_workspace.py +127 -0
- ai_agent/app/app.py +321 -0
- ai_agent/app/harness_io.py +109 -0
- ai_agent/app/output_format.py +77 -0
- ai_agent/app/packet.py +39 -0
- ai_agent/app/session.py +742 -0
- ai_agent/app/session_store.py +85 -0
- ai_agent/builtin_tools/__init__.py +18 -0
- ai_agent/builtin_tools/current_time.py +39 -0
- ai_agent/builtin_tools/pack.py +20 -0
- ai_agent/builtin_tools/prefix.py +11 -0
- ai_agent/context.py +151 -0
- ai_agent/harness/__init__.py +3 -0
- ai_agent/harness/current_time.py +25 -0
- ai_agent/harness/harness.py +324 -0
- ai_agent/harness/process.py +115 -0
- ai_agent/harness/prompts.py +38 -0
- ai_agent/harness/sandbox.py +139 -0
- ai_agent/json_extract.py +70 -0
- ai_agent/listener.py +172 -0
- ai_agent/llm.py +39 -0
- ai_agent/llm_openai.py +117 -0
- ai_agent/loop.py +124 -0
- ai_agent/mcp_config.py +54 -0
- ai_agent/mcp_loader.py +110 -0
- ai_agent/memory/__init__.py +9 -0
- ai_agent/memory/compression_work.py +71 -0
- ai_agent/memory/compressor.py +339 -0
- ai_agent/memory/config.py +40 -0
- ai_agent/memory/context_builder.py +57 -0
- ai_agent/memory/memory_system.py +561 -0
- ai_agent/memory/models.py +76 -0
- ai_agent/memory/snapshot_merge.py +158 -0
- ai_agent/memory/store.py +107 -0
- ai_agent/memory/worker.py +227 -0
- ai_agent/plan/__init__.py +15 -0
- ai_agent/plan/complete.py +64 -0
- ai_agent/plan/delivery.py +41 -0
- ai_agent/plan/display.py +46 -0
- ai_agent/plan/models.py +44 -0
- ai_agent/plan/parse.py +39 -0
- ai_agent/plan/planner.py +204 -0
- ai_agent/plan/runner.py +281 -0
- ai_agent/react_tool_turn.py +39 -0
- ai_agent/rule/__init__.py +3 -0
- ai_agent/rule/rules.py +36 -0
- ai_agent/skill/__init__.py +5 -0
- ai_agent/skill/builtin_registry.py +56 -0
- ai_agent/skill/catalog.py +104 -0
- ai_agent/skill/frontmatter.py +83 -0
- ai_agent/skill/manager.py +486 -0
- ai_agent/skill/models.py +31 -0
- ai_agent/skill/roots.py +150 -0
- ai_agent/skill/skill_kit.py +80 -0
- ai_agent/skill/tool_declarations.py +68 -0
- ai_agent/tools.py +123 -0
- python_library_ai_agent-0.1.0.dist-info/METADATA +10 -0
- python_library_ai_agent-0.1.0.dist-info/RECORD +62 -0
- python_library_ai_agent-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
from ai_agent.memory.compression_work import (
|
|
6
|
+
CompressImportantResult,
|
|
7
|
+
CompressImportantWork,
|
|
8
|
+
CompressLongResult,
|
|
9
|
+
CompressLongWork,
|
|
10
|
+
CompressionResult,
|
|
11
|
+
CompressionWork,
|
|
12
|
+
DateToLongResult,
|
|
13
|
+
DateToLongWork,
|
|
14
|
+
ShortToDateResult,
|
|
15
|
+
ShortToDateWork,
|
|
16
|
+
)
|
|
17
|
+
from ai_agent.memory.config import MemoryConfig
|
|
18
|
+
from ai_agent.memory.models import (
|
|
19
|
+
DateMemoryDay,
|
|
20
|
+
ImportantMemoryEntry,
|
|
21
|
+
MemorySnapshot,
|
|
22
|
+
)
|
|
23
|
+
from ai_agent.memory.worker import MemoryTask, MemoryTaskKind
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def prepare_work(
|
|
27
|
+
snapshot: MemorySnapshot,
|
|
28
|
+
task: MemoryTask,
|
|
29
|
+
*,
|
|
30
|
+
config: MemoryConfig,
|
|
31
|
+
) -> CompressionWork | None:
|
|
32
|
+
if task.kind == MemoryTaskKind.SHORT_TO_DATE:
|
|
33
|
+
batch_size = int(task.payload.get("batch_size", 1))
|
|
34
|
+
if not snapshot.short_term:
|
|
35
|
+
return None
|
|
36
|
+
take = min(batch_size, len(snapshot.short_term))
|
|
37
|
+
batch = tuple(
|
|
38
|
+
m.model_copy(deep=True) for m in snapshot.short_term[:take]
|
|
39
|
+
)
|
|
40
|
+
return ShortToDateWork(batch=batch, batch_size=take)
|
|
41
|
+
if task.kind == MemoryTaskKind.DATE_TO_LONG:
|
|
42
|
+
day_label = str(task.payload["day"])
|
|
43
|
+
day = _find_day(snapshot, day_label)
|
|
44
|
+
if day is None or not day.entries:
|
|
45
|
+
return None
|
|
46
|
+
entries = tuple(e.model_copy(deep=True) for e in day.entries)
|
|
47
|
+
return DateToLongWork(day_label=day_label, entries=entries)
|
|
48
|
+
if task.kind == MemoryTaskKind.COMPRESS_LONG:
|
|
49
|
+
if len(snapshot.long_term) <= config.long_term_max_chunks:
|
|
50
|
+
return None
|
|
51
|
+
chunks = tuple(c.model_copy(deep=True) for c in snapshot.long_term)
|
|
52
|
+
return CompressLongWork(chunks=chunks)
|
|
53
|
+
if task.kind == MemoryTaskKind.COMPRESS_IMPORTANT:
|
|
54
|
+
if len(snapshot.important) <= config.important_max_entries:
|
|
55
|
+
return None
|
|
56
|
+
entries = tuple(e.model_copy(deep=True) for e in snapshot.important)
|
|
57
|
+
return CompressImportantWork(entries=entries)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def apply_result(
|
|
62
|
+
snapshot: MemorySnapshot,
|
|
63
|
+
task: MemoryTask,
|
|
64
|
+
work: CompressionWork,
|
|
65
|
+
result: CompressionResult,
|
|
66
|
+
*,
|
|
67
|
+
config: MemoryConfig,
|
|
68
|
+
) -> list[MemoryTask]:
|
|
69
|
+
if task.kind == MemoryTaskKind.SHORT_TO_DATE:
|
|
70
|
+
return _apply_short_to_date(snapshot, work, result, config=config)
|
|
71
|
+
if task.kind == MemoryTaskKind.DATE_TO_LONG:
|
|
72
|
+
_apply_date_to_long(snapshot, work, result)
|
|
73
|
+
return []
|
|
74
|
+
if task.kind == MemoryTaskKind.COMPRESS_LONG:
|
|
75
|
+
_apply_compress_long(snapshot, result)
|
|
76
|
+
return []
|
|
77
|
+
if task.kind == MemoryTaskKind.COMPRESS_IMPORTANT:
|
|
78
|
+
_apply_compress_important(snapshot, result)
|
|
79
|
+
return []
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _apply_short_to_date(
|
|
84
|
+
snapshot: MemorySnapshot,
|
|
85
|
+
work: CompressionWork,
|
|
86
|
+
result: CompressionResult,
|
|
87
|
+
*,
|
|
88
|
+
config: MemoryConfig,
|
|
89
|
+
) -> list[MemoryTask]:
|
|
90
|
+
if not isinstance(work, ShortToDateWork) or not isinstance(
|
|
91
|
+
result,
|
|
92
|
+
ShortToDateResult,
|
|
93
|
+
):
|
|
94
|
+
return []
|
|
95
|
+
remove = min(work.batch_size, len(snapshot.short_term))
|
|
96
|
+
if remove:
|
|
97
|
+
snapshot.short_term = snapshot.short_term[remove:]
|
|
98
|
+
day = _find_or_create_day(snapshot, result.day_label)
|
|
99
|
+
day.entries.extend(result.entries)
|
|
100
|
+
now = datetime.now(timezone.utc)
|
|
101
|
+
for text in result.important_texts:
|
|
102
|
+
snapshot.important.append(
|
|
103
|
+
ImportantMemoryEntry(at=now, content=text, source="short_term"),
|
|
104
|
+
)
|
|
105
|
+
followups: list[MemoryTask] = []
|
|
106
|
+
if day is not None and len(day.entries) > config.date_memory_max_entries_per_day:
|
|
107
|
+
followups.append(
|
|
108
|
+
MemoryTask(MemoryTaskKind.DATE_TO_LONG, {"day": result.day_label}),
|
|
109
|
+
)
|
|
110
|
+
return followups
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _apply_date_to_long(
|
|
114
|
+
snapshot: MemorySnapshot,
|
|
115
|
+
work: CompressionWork,
|
|
116
|
+
result: CompressionResult,
|
|
117
|
+
) -> None:
|
|
118
|
+
if not isinstance(work, DateToLongWork) or not isinstance(
|
|
119
|
+
result,
|
|
120
|
+
DateToLongResult,
|
|
121
|
+
):
|
|
122
|
+
return
|
|
123
|
+
snapshot.long_term.append(result.chunk)
|
|
124
|
+
snapshot.date_days = [d for d in snapshot.date_days if d.date != work.day_label]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _apply_compress_long(
|
|
128
|
+
snapshot: MemorySnapshot,
|
|
129
|
+
result: CompressionResult,
|
|
130
|
+
) -> None:
|
|
131
|
+
if not isinstance(result, CompressLongResult):
|
|
132
|
+
return
|
|
133
|
+
snapshot.long_term = list(result.chunks)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _apply_compress_important(
|
|
137
|
+
snapshot: MemorySnapshot,
|
|
138
|
+
result: CompressionResult,
|
|
139
|
+
) -> None:
|
|
140
|
+
if not isinstance(result, CompressImportantResult):
|
|
141
|
+
return
|
|
142
|
+
snapshot.important = list(result.entries)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _find_or_create_day(snapshot: MemorySnapshot, day_label: str) -> DateMemoryDay:
|
|
146
|
+
existing = _find_day(snapshot, day_label)
|
|
147
|
+
if existing is not None:
|
|
148
|
+
return existing
|
|
149
|
+
day = DateMemoryDay(date=day_label, entries=[])
|
|
150
|
+
snapshot.date_days.append(day)
|
|
151
|
+
return day
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _find_day(snapshot: MemorySnapshot, day_label: str) -> DateMemoryDay | None:
|
|
155
|
+
for day in snapshot.date_days:
|
|
156
|
+
if day.date == day_label:
|
|
157
|
+
return day
|
|
158
|
+
return None
|
ai_agent/memory/store.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ai_agent.memory.models import (
|
|
7
|
+
DateMemoryDay,
|
|
8
|
+
ImportantMemoryEntry,
|
|
9
|
+
LongTermChunk,
|
|
10
|
+
MemoryMessage,
|
|
11
|
+
MemorySnapshot,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class MemoryStore:
|
|
16
|
+
"""将会话记忆读写为存储目录下的 JSON 文件。"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, root: Path) -> None:
|
|
19
|
+
self._root = root
|
|
20
|
+
self._short_term_path = root / "short_term.json"
|
|
21
|
+
self._date_dir = root / "date"
|
|
22
|
+
self._long_term_path = root / "long_term.json"
|
|
23
|
+
self._important_path = root / "important.json"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def root(self) -> Path:
|
|
27
|
+
return self._root
|
|
28
|
+
|
|
29
|
+
def ensure_layout(self) -> None:
|
|
30
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self._date_dir.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
def load(self) -> MemorySnapshot:
|
|
34
|
+
self.ensure_layout()
|
|
35
|
+
short_term = _read_list(self._short_term_path, MemoryMessage)
|
|
36
|
+
date_days = self._load_date_days()
|
|
37
|
+
long_term = _read_list(self._long_term_path, LongTermChunk)
|
|
38
|
+
important = _read_list(self._important_path, ImportantMemoryEntry)
|
|
39
|
+
return MemorySnapshot(
|
|
40
|
+
short_term=short_term,
|
|
41
|
+
date_days=date_days,
|
|
42
|
+
long_term=long_term,
|
|
43
|
+
important=important,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def save_short_term(self, messages: list[MemoryMessage]) -> None:
|
|
47
|
+
self.ensure_layout()
|
|
48
|
+
_write_list(self._short_term_path, messages)
|
|
49
|
+
|
|
50
|
+
def save_date_day(self, day: DateMemoryDay) -> None:
|
|
51
|
+
self.ensure_layout()
|
|
52
|
+
path = self._date_dir / f"{day.date}.json"
|
|
53
|
+
path.write_text(
|
|
54
|
+
day.model_dump_json(indent=2),
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def remove_date_day(self, date_label: str) -> None:
|
|
59
|
+
path = self._date_dir / f"{date_label}.json"
|
|
60
|
+
if path.is_file():
|
|
61
|
+
path.unlink()
|
|
62
|
+
|
|
63
|
+
def prune_date_files(self, active_dates: set[str]) -> None:
|
|
64
|
+
if not self._date_dir.is_dir():
|
|
65
|
+
return
|
|
66
|
+
for path in self._date_dir.glob("*.json"):
|
|
67
|
+
if path.stem not in active_dates:
|
|
68
|
+
path.unlink(missing_ok=True)
|
|
69
|
+
|
|
70
|
+
def save_long_term(self, chunks: list[LongTermChunk]) -> None:
|
|
71
|
+
self.ensure_layout()
|
|
72
|
+
_write_list(self._long_term_path, chunks)
|
|
73
|
+
|
|
74
|
+
def save_important(self, entries: list[ImportantMemoryEntry]) -> None:
|
|
75
|
+
self.ensure_layout()
|
|
76
|
+
_write_list(self._important_path, entries)
|
|
77
|
+
|
|
78
|
+
def _load_date_days(self) -> list[DateMemoryDay]:
|
|
79
|
+
if not self._date_dir.is_dir():
|
|
80
|
+
return []
|
|
81
|
+
days: list[DateMemoryDay] = []
|
|
82
|
+
for path in sorted(self._date_dir.glob("*.json")):
|
|
83
|
+
raw = path.read_text(encoding="utf-8")
|
|
84
|
+
if not raw.strip():
|
|
85
|
+
continue
|
|
86
|
+
days.append(DateMemoryDay.model_validate_json(raw))
|
|
87
|
+
return days
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _read_list(path: Path, model: type) -> list:
|
|
91
|
+
if not path.is_file():
|
|
92
|
+
return []
|
|
93
|
+
raw = path.read_text(encoding="utf-8")
|
|
94
|
+
if not raw.strip():
|
|
95
|
+
return []
|
|
96
|
+
data = json.loads(raw)
|
|
97
|
+
if not isinstance(data, list):
|
|
98
|
+
return []
|
|
99
|
+
return [model.model_validate(item) for item in data]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _write_list(path: Path, items: list) -> None:
|
|
103
|
+
payload = [item.model_dump(mode="json") for item in items]
|
|
104
|
+
path.write_text(
|
|
105
|
+
json.dumps(payload, ensure_ascii=False, indent=2),
|
|
106
|
+
encoding="utf-8",
|
|
107
|
+
)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import enum
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import date, datetime, timedelta, timezone
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from ai_agent.memory.compression_work import (
|
|
11
|
+
CompressImportantWork,
|
|
12
|
+
CompressLongWork,
|
|
13
|
+
CompressionResult,
|
|
14
|
+
CompressionWork,
|
|
15
|
+
CompressImportantResult,
|
|
16
|
+
CompressLongResult,
|
|
17
|
+
DateToLongResult,
|
|
18
|
+
DateToLongWork,
|
|
19
|
+
ShortToDateResult,
|
|
20
|
+
ShortToDateWork,
|
|
21
|
+
)
|
|
22
|
+
from ai_agent.memory.compressor import MemoryCompressor
|
|
23
|
+
from ai_agent.memory.config import MemoryConfig
|
|
24
|
+
class MemoryTaskKind(str, enum.Enum):
|
|
25
|
+
SHORT_TO_DATE = "short_to_date"
|
|
26
|
+
DATE_TO_LONG = "date_to_long"
|
|
27
|
+
COMPRESS_LONG = "compress_long"
|
|
28
|
+
COMPRESS_IMPORTANT = "compress_important"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class MemoryTask:
|
|
33
|
+
kind: MemoryTaskKind
|
|
34
|
+
payload: dict
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MemoryWorker:
|
|
38
|
+
"""
|
|
39
|
+
独立线程上的记忆维护:弹出短期、归档日期、压缩长期与重要记忆。
|
|
40
|
+
|
|
41
|
+
压缩在锁外执行;提交结果时由调用方短暂持锁合并并发布 Agent 视图。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
config: MemoryConfig,
|
|
48
|
+
compressor_factory: Callable[[], MemoryCompressor],
|
|
49
|
+
prepare: Callable[[MemoryTask], CompressionWork | None],
|
|
50
|
+
commit: Callable[[MemoryTask, CompressionWork, CompressionResult], None],
|
|
51
|
+
) -> None:
|
|
52
|
+
self._config = config
|
|
53
|
+
self._compressor_factory = compressor_factory
|
|
54
|
+
self._prepare = prepare
|
|
55
|
+
self._commit = commit
|
|
56
|
+
self._queue: queue.Queue[MemoryTask | None] = queue.Queue()
|
|
57
|
+
self._thread: threading.Thread | None = None
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
if self._thread is not None and self._thread.is_alive():
|
|
61
|
+
return
|
|
62
|
+
self._thread = threading.Thread(
|
|
63
|
+
target=self._thread_main,
|
|
64
|
+
name="memory-worker",
|
|
65
|
+
daemon=True,
|
|
66
|
+
)
|
|
67
|
+
self._thread.start()
|
|
68
|
+
|
|
69
|
+
def stop(self, *, join: bool = True, timeout: float | None = 5.0) -> None:
|
|
70
|
+
self._queue.put(None)
|
|
71
|
+
if join and self._thread is not None:
|
|
72
|
+
self._thread.join(timeout=timeout)
|
|
73
|
+
|
|
74
|
+
def enqueue(self, task: MemoryTask) -> None:
|
|
75
|
+
self._queue.put(task)
|
|
76
|
+
|
|
77
|
+
def drain(self, *, timeout: float = 30.0) -> None:
|
|
78
|
+
"""等待队列中已有任务处理完毕(不含后续新任务)。"""
|
|
79
|
+
done = threading.Event()
|
|
80
|
+
sentinel = MemoryTask(
|
|
81
|
+
MemoryTaskKind.COMPRESS_IMPORTANT,
|
|
82
|
+
{"drain": True, "done": done},
|
|
83
|
+
)
|
|
84
|
+
self._queue.put(sentinel)
|
|
85
|
+
if not done.wait(timeout=timeout):
|
|
86
|
+
raise TimeoutError("记忆压缩队列等待超时")
|
|
87
|
+
|
|
88
|
+
def _thread_main(self) -> None:
|
|
89
|
+
import asyncio
|
|
90
|
+
|
|
91
|
+
loop = asyncio.new_event_loop()
|
|
92
|
+
asyncio.set_event_loop(loop)
|
|
93
|
+
try:
|
|
94
|
+
loop.run_until_complete(self._run_loop())
|
|
95
|
+
finally:
|
|
96
|
+
loop.close()
|
|
97
|
+
|
|
98
|
+
async def _run_loop(self) -> None:
|
|
99
|
+
compressor = self._compressor_factory()
|
|
100
|
+
while True:
|
|
101
|
+
task = await _queue_get_async(self._queue)
|
|
102
|
+
if task is None:
|
|
103
|
+
return
|
|
104
|
+
if task.payload.get("drain"):
|
|
105
|
+
event = task.payload.get("done")
|
|
106
|
+
if isinstance(event, threading.Event):
|
|
107
|
+
event.set()
|
|
108
|
+
continue
|
|
109
|
+
work = self._prepare(task)
|
|
110
|
+
if work is None:
|
|
111
|
+
continue
|
|
112
|
+
result = await _process_task(compressor, task, work, self._config)
|
|
113
|
+
if result is None:
|
|
114
|
+
continue
|
|
115
|
+
self._commit(task, work, result)
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def config(self) -> MemoryConfig:
|
|
119
|
+
return self._config
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _process_task(
|
|
123
|
+
compressor: MemoryCompressor,
|
|
124
|
+
task: MemoryTask,
|
|
125
|
+
work: CompressionWork,
|
|
126
|
+
config: MemoryConfig,
|
|
127
|
+
) -> CompressionResult | None:
|
|
128
|
+
if task.kind == MemoryTaskKind.SHORT_TO_DATE:
|
|
129
|
+
return await _process_short_to_date(compressor, work)
|
|
130
|
+
if task.kind == MemoryTaskKind.DATE_TO_LONG:
|
|
131
|
+
return await _process_date_to_long(compressor, work)
|
|
132
|
+
if task.kind == MemoryTaskKind.COMPRESS_LONG:
|
|
133
|
+
return await _process_compress_long(compressor, work, config)
|
|
134
|
+
if task.kind == MemoryTaskKind.COMPRESS_IMPORTANT:
|
|
135
|
+
return await _process_compress_important(compressor, work, config)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _process_short_to_date(
|
|
140
|
+
compressor: MemoryCompressor,
|
|
141
|
+
work: CompressionWork,
|
|
142
|
+
) -> ShortToDateResult | None:
|
|
143
|
+
if not isinstance(work, ShortToDateWork) or not work.batch:
|
|
144
|
+
return None
|
|
145
|
+
batch = list(work.batch)
|
|
146
|
+
entries, important_texts = await compressor.compress_to_date_entries(batch)
|
|
147
|
+
day_label = _day_label(batch[0].at)
|
|
148
|
+
return ShortToDateResult(
|
|
149
|
+
entries=tuple(entries),
|
|
150
|
+
important_texts=tuple(important_texts),
|
|
151
|
+
day_label=day_label,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def _process_date_to_long(
|
|
156
|
+
compressor: MemoryCompressor,
|
|
157
|
+
work: CompressionWork,
|
|
158
|
+
) -> DateToLongResult | None:
|
|
159
|
+
if not isinstance(work, DateToLongWork):
|
|
160
|
+
return None
|
|
161
|
+
chunk = await compressor.merge_date_to_long_term(
|
|
162
|
+
work.day_label,
|
|
163
|
+
list(work.entries),
|
|
164
|
+
)
|
|
165
|
+
return DateToLongResult(chunk=chunk)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _process_compress_long(
|
|
169
|
+
compressor: MemoryCompressor,
|
|
170
|
+
work: CompressionWork,
|
|
171
|
+
config: MemoryConfig,
|
|
172
|
+
) -> CompressLongResult | None:
|
|
173
|
+
if not isinstance(work, CompressLongWork):
|
|
174
|
+
return None
|
|
175
|
+
if len(work.chunks) <= config.long_term_max_chunks:
|
|
176
|
+
return None
|
|
177
|
+
merged = await compressor.merge_long_term_chunks(list(work.chunks))
|
|
178
|
+
return CompressLongResult(chunks=tuple(merged))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _process_compress_important(
|
|
182
|
+
compressor: MemoryCompressor,
|
|
183
|
+
work: CompressionWork,
|
|
184
|
+
config: MemoryConfig,
|
|
185
|
+
) -> CompressImportantResult | None:
|
|
186
|
+
if not isinstance(work, CompressImportantWork):
|
|
187
|
+
return None
|
|
188
|
+
if len(work.entries) <= config.important_max_entries:
|
|
189
|
+
return None
|
|
190
|
+
merged = await compressor.reconcile_important(list(work.entries))
|
|
191
|
+
return CompressImportantResult(entries=tuple(merged))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def _queue_get_async(q: queue.Queue) -> MemoryTask | None:
|
|
195
|
+
import asyncio
|
|
196
|
+
|
|
197
|
+
while True:
|
|
198
|
+
try:
|
|
199
|
+
return q.get_nowait()
|
|
200
|
+
except queue.Empty:
|
|
201
|
+
await asyncio.sleep(0.05)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _day_label(at: datetime) -> str:
|
|
205
|
+
if at.tzinfo is None:
|
|
206
|
+
at = at.replace(tzinfo=timezone.utc)
|
|
207
|
+
return at.astimezone(timezone.utc).date().isoformat()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def expire_old_date_days(
|
|
211
|
+
snapshot,
|
|
212
|
+
*,
|
|
213
|
+
config: MemoryConfig,
|
|
214
|
+
today: date | None = None,
|
|
215
|
+
) -> list[str]:
|
|
216
|
+
"""返回应归档到长期记忆的过期日期标签。"""
|
|
217
|
+
ref = today or datetime.now(timezone.utc).date()
|
|
218
|
+
cutoff = ref - timedelta(days=config.date_memory_days - 1)
|
|
219
|
+
expired: list[str] = []
|
|
220
|
+
for day in snapshot.date_days:
|
|
221
|
+
try:
|
|
222
|
+
day_date = date.fromisoformat(day.date)
|
|
223
|
+
except ValueError:
|
|
224
|
+
continue
|
|
225
|
+
if day_date < cutoff:
|
|
226
|
+
expired.append(day.date)
|
|
227
|
+
return expired
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from ai_agent.plan.models import Plan, PlanRunResult, PlanStep
|
|
2
|
+
from ai_agent.plan.parse import PlanParseError, parse_plan_text
|
|
3
|
+
from ai_agent.plan.planner import PlanPlanner
|
|
4
|
+
from ai_agent.plan.runner import PlanRunner, PlanStepFailedError
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Plan",
|
|
8
|
+
"PlanParseError",
|
|
9
|
+
"parse_plan_text",
|
|
10
|
+
"PlanPlanner",
|
|
11
|
+
"PlanRunResult",
|
|
12
|
+
"PlanRunner",
|
|
13
|
+
"PlanStep",
|
|
14
|
+
"PlanStepFailedError",
|
|
15
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
|
|
5
|
+
from ai_agent.context import ChatMessage, RunContext, RunPhase, RunPhaseKind
|
|
6
|
+
from ai_agent.listener import (
|
|
7
|
+
AgentListener,
|
|
8
|
+
notify_output_delta,
|
|
9
|
+
notify_thinking_delta,
|
|
10
|
+
)
|
|
11
|
+
from ai_agent.llm import LLMClient, StreamKind
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def complete_text(
|
|
15
|
+
llm: LLMClient,
|
|
16
|
+
*,
|
|
17
|
+
system_prompt: str,
|
|
18
|
+
user_content: str,
|
|
19
|
+
history: list[ChatMessage] | None = None,
|
|
20
|
+
listeners: Sequence[AgentListener] | None = None,
|
|
21
|
+
phase: RunPhase | None = None,
|
|
22
|
+
parse_from_answer_text_only: bool = False,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""
|
|
25
|
+
无工具单次补全,收集流式文本。
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
llm: 与执行 Agent 共用的语言模型客户端
|
|
29
|
+
system_prompt: 系统提示
|
|
30
|
+
user_content: 本轮用户内容
|
|
31
|
+
history: 规划前对话历史(不含本轮 user)
|
|
32
|
+
parse_from_answer_text_only: 为 True 时返回值仅含 TEXT 流(思考流仍通知 listener,
|
|
33
|
+
但不参与返回,供规划 JSON 解析,避免思考草稿中的 JSON 覆盖正式回答)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
模型完整文本
|
|
37
|
+
"""
|
|
38
|
+
messages = list(history or [])
|
|
39
|
+
messages.append(ChatMessage(role="user", content=user_content))
|
|
40
|
+
planning_phase = phase or RunPhase(kind=RunPhaseKind.PLANNING)
|
|
41
|
+
run = RunContext(
|
|
42
|
+
system_prompt=system_prompt,
|
|
43
|
+
messages=messages,
|
|
44
|
+
phase=planning_phase,
|
|
45
|
+
)
|
|
46
|
+
active_listeners = list(listeners or [])
|
|
47
|
+
all_parts: list[str] = []
|
|
48
|
+
answer_parts: list[str] = []
|
|
49
|
+
async for chunk in llm.stream(run, tools=None):
|
|
50
|
+
if chunk.kind == StreamKind.REASONING and chunk.delta:
|
|
51
|
+
if not parse_from_answer_text_only:
|
|
52
|
+
all_parts.append(chunk.delta)
|
|
53
|
+
await notify_thinking_delta(active_listeners, chunk.delta, run)
|
|
54
|
+
elif chunk.kind == StreamKind.TEXT and chunk.delta:
|
|
55
|
+
all_parts.append(chunk.delta)
|
|
56
|
+
answer_parts.append(chunk.delta)
|
|
57
|
+
await notify_output_delta(active_listeners, chunk.delta, run)
|
|
58
|
+
if parse_from_answer_text_only:
|
|
59
|
+
text = "".join(answer_parts).strip()
|
|
60
|
+
else:
|
|
61
|
+
text = "".join(all_parts).strip()
|
|
62
|
+
if not text and run.output:
|
|
63
|
+
return run.output.strip()
|
|
64
|
+
return text
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from ai_agent.plan.models import PlanStep
|
|
6
|
+
|
|
7
|
+
_SKILL_REF_PATTERN = re.compile(r"\b[\w-]+/[\w-]+(?:/[\w-]+)*\b")
|
|
8
|
+
_ENABLE_TOOL_NAMES = frozenset({"enable_skill", "skill__enable_skill"})
|
|
9
|
+
_DELIVERY_SKILL_ALIASES: tuple[tuple[str, str], ...] = (
|
|
10
|
+
("skills/chat-search-answer", "skills/chat-search-answer"),
|
|
11
|
+
("chat-search-answer", "skills/chat-search-answer"),
|
|
12
|
+
("聊天搜索回答", "skills/chat-search-answer"),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
_PLAN_DELIVERY_PRELOAD_NOTE = (
|
|
16
|
+
"改写技能已载入本步系统上下文,无需再调用 enable_skill,"
|
|
17
|
+
"直接按技能正文原则交付终稿。"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def delivery_skill_refs_for_step(step: PlanStep) -> tuple[str, ...]:
|
|
22
|
+
"""
|
|
23
|
+
从计划步 objective / title 解析终稿改写 skill 引用。
|
|
24
|
+
|
|
25
|
+
匹配 ``{root_key}/{skill_id}`` 形式(如 ``skills/chat-search-answer``)。
|
|
26
|
+
"""
|
|
27
|
+
text = f"{step.title}\n{step.objective}"
|
|
28
|
+
refs: list[str] = []
|
|
29
|
+
for match in _SKILL_REF_PATTERN.finditer(text):
|
|
30
|
+
candidate = match.group(0)
|
|
31
|
+
if candidate not in refs and "/" in candidate:
|
|
32
|
+
refs.append(candidate)
|
|
33
|
+
for needle, ref in _DELIVERY_SKILL_ALIASES:
|
|
34
|
+
if needle in text and ref not in refs:
|
|
35
|
+
refs.append(ref)
|
|
36
|
+
return tuple(refs)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def plan_delivery_preload_note() -> str:
|
|
40
|
+
"""终稿步已预载 skill 时拼入用户消息的说明。"""
|
|
41
|
+
return _PLAN_DELIVERY_PRELOAD_NOTE
|
ai_agent/plan/display.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ai_agent.plan.models import Plan, PlanStep
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def format_plan_for_terminal(plan: Plan) -> str:
|
|
7
|
+
"""
|
|
8
|
+
将计划格式化为终端可读的固定版式文本。
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
plan: 规划结果
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
多行字符串,不含首尾空行
|
|
15
|
+
"""
|
|
16
|
+
lines: list[str] = ["--- plan ---"]
|
|
17
|
+
if plan.summary and plan.summary.strip():
|
|
18
|
+
lines.append(f"摘要: {plan.summary.strip()}")
|
|
19
|
+
total = len(plan.steps)
|
|
20
|
+
lines.append(f"步骤(共 {total} 步):")
|
|
21
|
+
for index, step in enumerate(plan.steps, start=1):
|
|
22
|
+
lines.extend(_format_step_lines(index, step))
|
|
23
|
+
return "\n".join(lines)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_step_lines(index: int, step: PlanStep) -> list[str]:
|
|
27
|
+
optional_tag = "(可选)" if step.optional else ""
|
|
28
|
+
head = f" {index}. {step.id} · {step.title}{optional_tag}"
|
|
29
|
+
body: list[str] = [head]
|
|
30
|
+
objective = step.objective.strip()
|
|
31
|
+
if objective:
|
|
32
|
+
for line in objective.splitlines():
|
|
33
|
+
body.append(f" 目标: {line}")
|
|
34
|
+
tools_line = _format_tools_line(step)
|
|
35
|
+
if tools_line:
|
|
36
|
+
body.append(f" {tools_line}")
|
|
37
|
+
return body
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _format_tools_line(step: PlanStep) -> str:
|
|
41
|
+
parts: list[str] = []
|
|
42
|
+
if step.hint_tools:
|
|
43
|
+
parts.append(f"建议工具: {', '.join(step.hint_tools)}")
|
|
44
|
+
if step.required_tool:
|
|
45
|
+
parts.append(f"须用工具: {step.required_tool}")
|
|
46
|
+
return ";".join(parts)
|