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.
Files changed (62) hide show
  1. ai_agent/__init__.py +66 -0
  2. ai_agent/agent.py +122 -0
  3. ai_agent/app/__init__.py +10 -0
  4. ai_agent/app/_workspace.py +127 -0
  5. ai_agent/app/app.py +321 -0
  6. ai_agent/app/harness_io.py +109 -0
  7. ai_agent/app/output_format.py +77 -0
  8. ai_agent/app/packet.py +39 -0
  9. ai_agent/app/session.py +742 -0
  10. ai_agent/app/session_store.py +85 -0
  11. ai_agent/builtin_tools/__init__.py +18 -0
  12. ai_agent/builtin_tools/current_time.py +39 -0
  13. ai_agent/builtin_tools/pack.py +20 -0
  14. ai_agent/builtin_tools/prefix.py +11 -0
  15. ai_agent/context.py +151 -0
  16. ai_agent/harness/__init__.py +3 -0
  17. ai_agent/harness/current_time.py +25 -0
  18. ai_agent/harness/harness.py +324 -0
  19. ai_agent/harness/process.py +115 -0
  20. ai_agent/harness/prompts.py +38 -0
  21. ai_agent/harness/sandbox.py +139 -0
  22. ai_agent/json_extract.py +70 -0
  23. ai_agent/listener.py +172 -0
  24. ai_agent/llm.py +39 -0
  25. ai_agent/llm_openai.py +117 -0
  26. ai_agent/loop.py +124 -0
  27. ai_agent/mcp_config.py +54 -0
  28. ai_agent/mcp_loader.py +110 -0
  29. ai_agent/memory/__init__.py +9 -0
  30. ai_agent/memory/compression_work.py +71 -0
  31. ai_agent/memory/compressor.py +339 -0
  32. ai_agent/memory/config.py +40 -0
  33. ai_agent/memory/context_builder.py +57 -0
  34. ai_agent/memory/memory_system.py +561 -0
  35. ai_agent/memory/models.py +76 -0
  36. ai_agent/memory/snapshot_merge.py +158 -0
  37. ai_agent/memory/store.py +107 -0
  38. ai_agent/memory/worker.py +227 -0
  39. ai_agent/plan/__init__.py +15 -0
  40. ai_agent/plan/complete.py +64 -0
  41. ai_agent/plan/delivery.py +41 -0
  42. ai_agent/plan/display.py +46 -0
  43. ai_agent/plan/models.py +44 -0
  44. ai_agent/plan/parse.py +39 -0
  45. ai_agent/plan/planner.py +204 -0
  46. ai_agent/plan/runner.py +281 -0
  47. ai_agent/react_tool_turn.py +39 -0
  48. ai_agent/rule/__init__.py +3 -0
  49. ai_agent/rule/rules.py +36 -0
  50. ai_agent/skill/__init__.py +5 -0
  51. ai_agent/skill/builtin_registry.py +56 -0
  52. ai_agent/skill/catalog.py +104 -0
  53. ai_agent/skill/frontmatter.py +83 -0
  54. ai_agent/skill/manager.py +486 -0
  55. ai_agent/skill/models.py +31 -0
  56. ai_agent/skill/roots.py +150 -0
  57. ai_agent/skill/skill_kit.py +80 -0
  58. ai_agent/skill/tool_declarations.py +68 -0
  59. ai_agent/tools.py +123 -0
  60. python_library_ai_agent-0.1.0.dist-info/METADATA +10 -0
  61. python_library_ai_agent-0.1.0.dist-info/RECORD +62 -0
  62. 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
@@ -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
@@ -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)