aisha 0.2.2__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.
- aisha/__init__.py +4 -0
- aisha/__main__.py +5 -0
- aisha/agent.py +244 -0
- aisha/cli.py +236 -0
- aisha/client.py +293 -0
- aisha/config.py +333 -0
- aisha/context.py +318 -0
- aisha/errors.py +38 -0
- aisha/fsutil.py +40 -0
- aisha/memory.py +142 -0
- aisha/skills.py +78 -0
- aisha/tools/__init__.py +6 -0
- aisha/tools/base.py +205 -0
- aisha/tools/extras.py +202 -0
- aisha/tools/files.py +380 -0
- aisha/tools/shell.py +177 -0
- aisha/tools/web.py +171 -0
- aisha/ui.py +550 -0
- aisha-0.2.2.dist-info/METADATA +294 -0
- aisha-0.2.2.dist-info/RECORD +23 -0
- aisha-0.2.2.dist-info/WHEEL +4 -0
- aisha-0.2.2.dist-info/entry_points.txt +2 -0
- aisha-0.2.2.dist-info/licenses/LICENSE +21 -0
aisha/__init__.py
ADDED
aisha/__main__.py
ADDED
aisha/agent.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# Author: Tischenko A. (https://github.com/cruide)
|
|
2
|
+
"""AgentLoop: model -> tool calls -> tool results -> model, with limits and compaction."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from aisha.client import ChatResponse, LlamaClient, ToolCall
|
|
11
|
+
from aisha.config import Config
|
|
12
|
+
from aisha.context import ConversationContext
|
|
13
|
+
from aisha.errors import AishaError
|
|
14
|
+
from aisha.tools.base import ToolContext, ToolRegistry, ToolResult
|
|
15
|
+
|
|
16
|
+
# Read-only tools without side effects: consecutive calls run concurrently.
|
|
17
|
+
PARALLEL_TOOLS = frozenset({
|
|
18
|
+
"read_file", "list_dir", "glob", "grep", "web_search", "web_fetch", "memory_get", "memory_list",
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
SUMMARY_SYSTEM = (
|
|
22
|
+
"Ты сжимаешь историю диалога AI-агента для программиста. Составь структурированную сводку "
|
|
23
|
+
"на русском: 1) цель пользователя; 2) что уже сделано (файлы, команды, результаты); "
|
|
24
|
+
"3) важные факты и решения; 4) незавершённые задачи и следующие шаги. Без воды, без "
|
|
25
|
+
"инструментов, только текст."
|
|
26
|
+
)
|
|
27
|
+
SUMMARY_REQUEST = "Сделай сводку диалога выше по указанной структуре."
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AgentEvents(Protocol):
|
|
31
|
+
def on_stream_start(self) -> None: ...
|
|
32
|
+
def on_text(self, delta: str) -> None: ...
|
|
33
|
+
def on_reasoning(self, delta: str) -> None: ...
|
|
34
|
+
def on_stream_end(self, response: ChatResponse) -> None: ...
|
|
35
|
+
def on_tool_start(self, call: ToolCall, args: dict[str, Any] | None) -> None: ...
|
|
36
|
+
def on_tool_end(self, call: ToolCall, result: ToolResult) -> None: ...
|
|
37
|
+
def on_notice(self, text: str, level: str = "info") -> None: ...
|
|
38
|
+
def on_debug(self, title: str, body: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AgentLoop:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
config: Config,
|
|
45
|
+
client: LlamaClient,
|
|
46
|
+
registry: ToolRegistry,
|
|
47
|
+
context: ConversationContext,
|
|
48
|
+
tool_ctx: ToolContext,
|
|
49
|
+
events: AgentEvents,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.config = config
|
|
52
|
+
self.client = client
|
|
53
|
+
self.registry = registry
|
|
54
|
+
self.context = context
|
|
55
|
+
self.tool_ctx = tool_ctx
|
|
56
|
+
self.events = events
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------ turn
|
|
59
|
+
async def run(self, user_text: str) -> str:
|
|
60
|
+
self.context.add_user(user_text)
|
|
61
|
+
try:
|
|
62
|
+
return await self._run_turn()
|
|
63
|
+
except BaseException:
|
|
64
|
+
# Keep history valid if we were interrupted between tool_calls and results.
|
|
65
|
+
self.context.close_dangling_tool_calls("Операция прервана пользователем.")
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
async def _run_turn(self) -> str:
|
|
69
|
+
llm = self.config.llm
|
|
70
|
+
iterations = 0
|
|
71
|
+
limit_hit = False
|
|
72
|
+
skip_compact = False
|
|
73
|
+
while True:
|
|
74
|
+
if not skip_compact and self.context.needs_compaction():
|
|
75
|
+
await self.compact()
|
|
76
|
+
if self.context.needs_compaction():
|
|
77
|
+
skip_compact = True
|
|
78
|
+
self.events.on_notice(
|
|
79
|
+
"Сжатие не освободило достаточно контекста; "
|
|
80
|
+
"продолжаю без повторной попытки.",
|
|
81
|
+
"warn",
|
|
82
|
+
)
|
|
83
|
+
tools = None if limit_hit else self.registry.schemas(read_only=self.config.read_only)
|
|
84
|
+
response = await self._call_model(tools)
|
|
85
|
+
self.context.add_assistant(response)
|
|
86
|
+
if response.finish_reason == "length":
|
|
87
|
+
self.events.on_notice("Ответ обрезан: достигнут лимит max_output_tokens.", "warn")
|
|
88
|
+
if not response.tool_calls:
|
|
89
|
+
return response.content
|
|
90
|
+
if limit_hit:
|
|
91
|
+
self._refuse_calls(response.tool_calls, "Инструменты недоступны: лимит исчерпан.")
|
|
92
|
+
return response.content
|
|
93
|
+
iterations += 1
|
|
94
|
+
if iterations > llm.max_tool_iterations:
|
|
95
|
+
self.events.on_notice(
|
|
96
|
+
f"Достигнут лимит итераций инструментов ({llm.max_tool_iterations}); "
|
|
97
|
+
"запрашиваю итоговый ответ.", "warn",
|
|
98
|
+
)
|
|
99
|
+
self._refuse_calls(
|
|
100
|
+
response.tool_calls,
|
|
101
|
+
"Лимит итераций инструментов исчерпан. Сформируй итоговый ответ для "
|
|
102
|
+
"пользователя без новых вызовов инструментов.",
|
|
103
|
+
)
|
|
104
|
+
limit_hit = True
|
|
105
|
+
continue
|
|
106
|
+
await self._execute_calls(response.tool_calls)
|
|
107
|
+
skip_compact = False
|
|
108
|
+
|
|
109
|
+
def _refuse_calls(self, calls: list[ToolCall], message: str) -> None:
|
|
110
|
+
for call in calls:
|
|
111
|
+
result = ToolResult.failure("IterationLimit", message)
|
|
112
|
+
self.context.add_tool_result(call.id, call.name, result.to_json())
|
|
113
|
+
|
|
114
|
+
async def _call_model(self, tools: list[dict[str, Any]] | None) -> ChatResponse:
|
|
115
|
+
llm = self.config.llm
|
|
116
|
+
messages = self.context.all_messages()
|
|
117
|
+
chars_in = self.context.sent_chars()
|
|
118
|
+
est_in = self.context.estimate_sent_tokens()
|
|
119
|
+
sampling = {
|
|
120
|
+
key: value for key, value in (
|
|
121
|
+
("top_p", llm.top_p),
|
|
122
|
+
("top_k", llm.top_k),
|
|
123
|
+
("repeat_penalty", llm.repeat_penalty),
|
|
124
|
+
("frequency_penalty", llm.frequency_penalty),
|
|
125
|
+
) if value is not None
|
|
126
|
+
}
|
|
127
|
+
remaining = llm.context_window - est_in
|
|
128
|
+
max_tokens = max(256, min(llm.max_output_tokens, remaining))
|
|
129
|
+
if self.config.ui.debug:
|
|
130
|
+
self.events.on_debug("→ model", self._format_request(messages, est_in))
|
|
131
|
+
self.events.on_stream_start()
|
|
132
|
+
response = await self.client.chat(
|
|
133
|
+
messages, tools, temperature=llm.temperature,
|
|
134
|
+
max_tokens=max_tokens, on_event=self._on_event,
|
|
135
|
+
sampling=sampling or None,
|
|
136
|
+
)
|
|
137
|
+
produced = response.content + response.reasoning + "".join(
|
|
138
|
+
c.arguments for c in response.tool_calls
|
|
139
|
+
)
|
|
140
|
+
self.context.stats.record(response.usage, est_in, self.context.estimate_text(produced),
|
|
141
|
+
chars_in)
|
|
142
|
+
self.events.on_stream_end(response)
|
|
143
|
+
if self.config.ui.debug:
|
|
144
|
+
self.events.on_debug("← model", self._format_response(response))
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
def _on_event(self, kind: str, delta: str) -> None:
|
|
148
|
+
if kind == "text":
|
|
149
|
+
self.events.on_text(delta)
|
|
150
|
+
elif kind == "reasoning":
|
|
151
|
+
self.events.on_reasoning(delta)
|
|
152
|
+
|
|
153
|
+
@staticmethod
|
|
154
|
+
def _clip(text: str, limit: int) -> str:
|
|
155
|
+
text = text.strip()
|
|
156
|
+
return text if len(text) <= limit else text[:limit] + "…"
|
|
157
|
+
|
|
158
|
+
def _format_request(self, messages: list[dict[str, Any]], est_tokens: int) -> str:
|
|
159
|
+
lines = [f"сообщений: {len(messages)}, ~{est_tokens} токенов"]
|
|
160
|
+
for m in messages:
|
|
161
|
+
role = m.get("role")
|
|
162
|
+
if m.get("tool_calls"):
|
|
163
|
+
names = [c.get("function", {}).get("name", "?") for c in m["tool_calls"]]
|
|
164
|
+
lines.append(f" {role}: [tool_calls] {', '.join(names)}")
|
|
165
|
+
continue
|
|
166
|
+
content = m.get("content")
|
|
167
|
+
body = content if isinstance(content, str) else json.dumps(content,
|
|
168
|
+
ensure_ascii=False)
|
|
169
|
+
lines.append(f" {role}: {self._clip(body, 240)}")
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
def _format_response(self, response: ChatResponse) -> str:
|
|
173
|
+
parts: list[str] = []
|
|
174
|
+
if response.reasoning:
|
|
175
|
+
parts.append(f"reasoning: {self._clip(response.reasoning, 600)}")
|
|
176
|
+
if response.content:
|
|
177
|
+
parts.append(f"content: {self._clip(response.content, 600)}")
|
|
178
|
+
for c in response.tool_calls:
|
|
179
|
+
parts.append(f"tool_call: {c.name}({self._clip(c.arguments, 300)})")
|
|
180
|
+
if response.finish_reason:
|
|
181
|
+
parts.append(f"finish_reason: {response.finish_reason}")
|
|
182
|
+
if response.usage:
|
|
183
|
+
parts.append(f"usage: {response.usage}")
|
|
184
|
+
return "\n".join(parts) or "(пусто)"
|
|
185
|
+
|
|
186
|
+
# ----------------------------------------------------------------- tools
|
|
187
|
+
async def _execute_calls(self, calls: list[ToolCall]) -> None:
|
|
188
|
+
i = 0
|
|
189
|
+
while i < len(calls):
|
|
190
|
+
if calls[i].name in PARALLEL_TOOLS:
|
|
191
|
+
j = i
|
|
192
|
+
while j < len(calls) and calls[j].name in PARALLEL_TOOLS:
|
|
193
|
+
j += 1
|
|
194
|
+
await asyncio.gather(*(self._run_call(c) for c in calls[i:j]))
|
|
195
|
+
i = j
|
|
196
|
+
else:
|
|
197
|
+
await self._run_call(calls[i])
|
|
198
|
+
i += 1
|
|
199
|
+
|
|
200
|
+
async def _run_call(self, call: ToolCall) -> None:
|
|
201
|
+
tool = self.registry.get(call.name)
|
|
202
|
+
silent = bool(tool and tool.silent)
|
|
203
|
+
try:
|
|
204
|
+
args = call.parse_arguments()
|
|
205
|
+
except ValueError as exc:
|
|
206
|
+
if not silent:
|
|
207
|
+
self.events.on_tool_start(call, None)
|
|
208
|
+
result = ToolResult.failure("ToolValidationError",
|
|
209
|
+
f"Некорректный JSON аргументов: {exc}")
|
|
210
|
+
else:
|
|
211
|
+
if not silent:
|
|
212
|
+
self.events.on_tool_start(call, args)
|
|
213
|
+
result = await self.registry.execute(call.name, args, self.tool_ctx)
|
|
214
|
+
if not silent:
|
|
215
|
+
self.events.on_tool_end(call, result)
|
|
216
|
+
self.context.add_tool_result(call.id, call.name, result.to_json())
|
|
217
|
+
if self.config.ui.debug and not silent:
|
|
218
|
+
self.events.on_debug(f"tool: {call.name}", self._clip(result.to_json(), 2000))
|
|
219
|
+
|
|
220
|
+
# ------------------------------------------------------------ compaction
|
|
221
|
+
async def compact(self, *, force: bool = False) -> bool:
|
|
222
|
+
blocks = self.context.turn_blocks()
|
|
223
|
+
if len(blocks) < 2:
|
|
224
|
+
if force:
|
|
225
|
+
self.events.on_notice("История слишком короткая, сжимать нечего.")
|
|
226
|
+
return False
|
|
227
|
+
old = [m for block in blocks[:-1] for m in block]
|
|
228
|
+
keep = blocks[-1]
|
|
229
|
+
self.events.on_notice("Сжимаю историю диалога…")
|
|
230
|
+
summary: str | None = None
|
|
231
|
+
try:
|
|
232
|
+
response = await self.client.chat(
|
|
233
|
+
[{"role": "system", "content": SUMMARY_SYSTEM}, *old,
|
|
234
|
+
{"role": "user", "content": SUMMARY_REQUEST}],
|
|
235
|
+
None, temperature=0.1, max_tokens=2048,
|
|
236
|
+
)
|
|
237
|
+
summary = response.content.strip() or None
|
|
238
|
+
except AishaError as exc:
|
|
239
|
+
self.events.on_notice(f"Сводка не удалась ({exc}); старые сообщения удалены.", "warn")
|
|
240
|
+
self.context.replace_history(summary, keep)
|
|
241
|
+
self.events.on_notice(
|
|
242
|
+
f"История сжата: {len(old)} сообщений → {'сводка' if summary else 'удалены'}."
|
|
243
|
+
)
|
|
244
|
+
return True
|
aisha/cli.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# Author: Tischenko A. (https://github.com/cruide)
|
|
2
|
+
"""Entry point: argument parsing, wiring, --doctor, one-shot and REPL modes."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import asyncio
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from aisha import __version__
|
|
14
|
+
from aisha.agent import AgentLoop
|
|
15
|
+
from aisha.client import LlamaClient
|
|
16
|
+
from aisha.config import Config, load_config
|
|
17
|
+
from aisha.context import ConversationContext, build_tool_guide
|
|
18
|
+
from aisha.errors import AishaError, ConfigurationError
|
|
19
|
+
from aisha.memory import MemoryStore
|
|
20
|
+
from aisha.skills import SkillIndex
|
|
21
|
+
from aisha.tools.base import ToolContext, ToolRegistry
|
|
22
|
+
from aisha.tools.extras import (
|
|
23
|
+
AskUserTool,
|
|
24
|
+
MemoryGetTool,
|
|
25
|
+
MemoryListTool,
|
|
26
|
+
MemoryReplaceTool,
|
|
27
|
+
MemorySetTool,
|
|
28
|
+
SkillTool,
|
|
29
|
+
TodoWriteTool,
|
|
30
|
+
)
|
|
31
|
+
from aisha.tools.files import (
|
|
32
|
+
EditFileTool,
|
|
33
|
+
GlobTool,
|
|
34
|
+
GrepTool,
|
|
35
|
+
ListDirTool,
|
|
36
|
+
ReadFileTool,
|
|
37
|
+
WriteFileTool,
|
|
38
|
+
)
|
|
39
|
+
from aisha.tools.shell import RunCommandTool
|
|
40
|
+
from aisha.tools.web import WebFetchTool, WebSearchTool
|
|
41
|
+
from aisha.ui import ConsoleUI
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
45
|
+
p = argparse.ArgumentParser(prog="aisha", description="Локальный консольный AI-агент.")
|
|
46
|
+
p.add_argument("prompt", nargs="*", help="одноразовый запрос (без него — REPL)")
|
|
47
|
+
p.add_argument("--server", help="URL llama-server, например http://localhost:8088")
|
|
48
|
+
p.add_argument("--model", help="имя модели (alias -a на сервере)")
|
|
49
|
+
p.add_argument("--api-key", help="API-ключ для сервера (если требуется авторизация)")
|
|
50
|
+
p.add_argument("-r", "--read-only", action="store_true", help="режим только для чтения")
|
|
51
|
+
p.add_argument("--permission", choices=("auto", "ask", "deny"), help="режим shell")
|
|
52
|
+
p.add_argument("--shell", choices=("powershell", "cmd"), help="оболочка по умолчанию")
|
|
53
|
+
p.add_argument("--tools-only", action="store_true", help="показать инструменты и выйти")
|
|
54
|
+
p.add_argument("--doctor", action="store_true", help="диагностика подключения")
|
|
55
|
+
p.add_argument("--tool-call-test", action="store_true",
|
|
56
|
+
help="с --doctor: проверить tool calling")
|
|
57
|
+
p.add_argument("--no-color", action="store_true", help="отключить цвета")
|
|
58
|
+
p.add_argument("--debug", action="store_true",
|
|
59
|
+
help="режим отладки: reasoning модели, дампы запросов/ответов, traceback")
|
|
60
|
+
p.add_argument("--version", action="version", version=f"aisha {__version__}")
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def cli_overrides(args: argparse.Namespace) -> dict[str, dict[str, Any]]:
|
|
65
|
+
over: dict[str, dict[str, Any]] = {}
|
|
66
|
+
if args.server:
|
|
67
|
+
over.setdefault("server", {})["base_url"] = args.server
|
|
68
|
+
if args.model:
|
|
69
|
+
over.setdefault("server", {})["model"] = args.model
|
|
70
|
+
if args.api_key:
|
|
71
|
+
over.setdefault("server", {})["api_key"] = args.api_key
|
|
72
|
+
if args.permission:
|
|
73
|
+
over.setdefault("tools", {})["permission"] = args.permission
|
|
74
|
+
if args.shell:
|
|
75
|
+
over.setdefault("tools", {})["shell_type"] = args.shell
|
|
76
|
+
if args.debug:
|
|
77
|
+
over.setdefault("ui", {})["debug"] = True
|
|
78
|
+
return over
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_registry(config: Config) -> ToolRegistry:
|
|
82
|
+
registry = ToolRegistry()
|
|
83
|
+
for tool in (ReadFileTool(), WriteFileTool(), EditFileTool(), ListDirTool(), GlobTool(),
|
|
84
|
+
GrepTool()):
|
|
85
|
+
registry.register(tool)
|
|
86
|
+
if config.tools.shell:
|
|
87
|
+
registry.register(RunCommandTool())
|
|
88
|
+
if config.tools.web_search:
|
|
89
|
+
registry.register(WebSearchTool())
|
|
90
|
+
registry.register(WebFetchTool())
|
|
91
|
+
registry.register(TodoWriteTool())
|
|
92
|
+
registry.register(AskUserTool())
|
|
93
|
+
registry.register(SkillTool())
|
|
94
|
+
if config.memory.enabled:
|
|
95
|
+
for tool in (MemoryListTool(), MemoryGetTool(), MemorySetTool(), MemoryReplaceTool()):
|
|
96
|
+
registry.register(tool)
|
|
97
|
+
return registry
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def run_doctor(config: Config, client: LlamaClient, ui: ConsoleUI,
|
|
101
|
+
tool_call_test: bool = False) -> bool:
|
|
102
|
+
ok_all = True
|
|
103
|
+
|
|
104
|
+
def report(ok: bool, label: str, detail: str = "", warn: bool = False) -> None:
|
|
105
|
+
nonlocal ok_all
|
|
106
|
+
if not ok and not warn:
|
|
107
|
+
ok_all = False
|
|
108
|
+
mark = "[green]✓[/]" if ok else ("[yellow]⚠[/]" if warn else "[red]✗[/]")
|
|
109
|
+
ui.console.print(f" {mark} {label}" + (f" [dim]— {detail}[/]" if detail else ""))
|
|
110
|
+
|
|
111
|
+
ui.console.print(f"[bold]Диагностика[/] {config.server.base_url}")
|
|
112
|
+
try:
|
|
113
|
+
health = await client.health()
|
|
114
|
+
report(True, "/health", str(health.get("status", "ok")))
|
|
115
|
+
except AishaError as exc:
|
|
116
|
+
report(False, "/health", str(exc))
|
|
117
|
+
ui.info("Проверьте, что llama-server запущен (команда — в README.md).")
|
|
118
|
+
return False
|
|
119
|
+
try:
|
|
120
|
+
info = await client.model_info()
|
|
121
|
+
names = list(info)
|
|
122
|
+
report(bool(names), "/v1/models", ", ".join(names) or "пусто")
|
|
123
|
+
if not names:
|
|
124
|
+
return False
|
|
125
|
+
if client.model in info:
|
|
126
|
+
model, matched = client.model, True
|
|
127
|
+
else:
|
|
128
|
+
model, matched = names[0], False
|
|
129
|
+
client.model = model
|
|
130
|
+
report(matched, "модель", model if matched else
|
|
131
|
+
f"'{config.server.model}' не найдена, используется '{model}'", warn=True)
|
|
132
|
+
except AishaError as exc:
|
|
133
|
+
report(False, "/v1/models", str(exc))
|
|
134
|
+
return False
|
|
135
|
+
try:
|
|
136
|
+
resp = await client.chat([{"role": "user", "content": "Ответь одним словом: ok"}],
|
|
137
|
+
None, temperature=0.0, max_tokens=64)
|
|
138
|
+
report(bool(resp.content.strip()), "/v1/chat/completions",
|
|
139
|
+
f"ответ: {resp.content.strip()[:40]!r}, usage: {'есть' if resp.usage else 'нет'}")
|
|
140
|
+
except AishaError as exc:
|
|
141
|
+
report(False, "/v1/chat/completions", str(exc))
|
|
142
|
+
if tool_call_test:
|
|
143
|
+
echo = {"type": "function", "function": {
|
|
144
|
+
"name": "echo", "description": "Вернуть переданный текст без изменений",
|
|
145
|
+
"parameters": {"type": "object", "properties": {"text": {"type": "string"}},
|
|
146
|
+
"required": ["text"]}}}
|
|
147
|
+
try:
|
|
148
|
+
resp = await client.chat(
|
|
149
|
+
[{"role": "user", "content": "Вызови инструмент echo с текстом 'ping'."}],
|
|
150
|
+
[echo], temperature=0.0, max_tokens=1024,
|
|
151
|
+
)
|
|
152
|
+
calls = [f"{c.name}({c.arguments})" for c in resp.tool_calls]
|
|
153
|
+
report(any(c.name == "echo" for c in resp.tool_calls), "tool calling",
|
|
154
|
+
", ".join(calls) or f"инструмент не вызван: {resp.content[:60]!r}")
|
|
155
|
+
except AishaError as exc:
|
|
156
|
+
report(False, "tool calling", str(exc))
|
|
157
|
+
ui.console.print("[green]Готово: всё в порядке.[/]" if ok_all else
|
|
158
|
+
"[red]Обнаружены проблемы.[/]")
|
|
159
|
+
return ok_all
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def _amain(args: argparse.Namespace) -> int:
|
|
163
|
+
workspace = Path.cwd().resolve()
|
|
164
|
+
no_color = args.no_color or bool(os.environ.get("NO_COLOR"))
|
|
165
|
+
ui = ConsoleUI(no_color=no_color, debug=args.debug)
|
|
166
|
+
try:
|
|
167
|
+
config = load_config(workspace, cli=cli_overrides(args), read_only=args.read_only)
|
|
168
|
+
except ConfigurationError as exc:
|
|
169
|
+
ui.error(f"Ошибка конфигурации: {exc}")
|
|
170
|
+
return 2
|
|
171
|
+
|
|
172
|
+
registry = build_registry(config)
|
|
173
|
+
if args.tools_only:
|
|
174
|
+
ui.print_tools(registry)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
client = LlamaClient(config.server.base_url, config.server.model,
|
|
178
|
+
api_key=config.server.api_key,
|
|
179
|
+
connect_timeout=config.server.connect_timeout,
|
|
180
|
+
request_timeout=config.server.request_timeout)
|
|
181
|
+
try:
|
|
182
|
+
if args.doctor:
|
|
183
|
+
return 0 if await run_doctor(config, client, ui, args.tool_call_test) else 1
|
|
184
|
+
try:
|
|
185
|
+
model, matched, n_ctx = await client.resolve_model_meta()
|
|
186
|
+
except AishaError as exc:
|
|
187
|
+
ui.error(str(exc), exc)
|
|
188
|
+
ui.info("Подсказка: aisha --doctor покажет подробности; сервер должен слушать "
|
|
189
|
+
f"{config.server.base_url}.")
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
# if not matched:
|
|
193
|
+
# ui.warn(f"Модель '{config.server.model}' не найдена на сервере, "
|
|
194
|
+
# f"используется '{model}'.")
|
|
195
|
+
|
|
196
|
+
if n_ctx:
|
|
197
|
+
config.llm.context_window = n_ctx
|
|
198
|
+
config.llm.max_output_tokens = n_ctx
|
|
199
|
+
|
|
200
|
+
memory = (MemoryStore(config.home_dir / "memory", config.project_dir / "memory",
|
|
201
|
+
max_block_chars=config.memory.max_block_chars)
|
|
202
|
+
if config.memory.enabled else None)
|
|
203
|
+
skills = SkillIndex(config.home_dir / "skills", config.project_dir / "skills")
|
|
204
|
+
tool_guide = (build_tool_guide(registry.schemas(read_only=config.read_only))
|
|
205
|
+
if config.llm.tool_guide else "")
|
|
206
|
+
context = ConversationContext(config, memory, skills, tool_guide)
|
|
207
|
+
ui.attach(config, context, client)
|
|
208
|
+
tool_ctx = ToolContext(
|
|
209
|
+
workspace=workspace, config=config, memory=memory, skills=skills,
|
|
210
|
+
todos=context.todos, confirm=ui.confirm if ui.interactive else None,
|
|
211
|
+
ask=ui.ask_user if ui.interactive else None, interactive=ui.interactive,
|
|
212
|
+
on_system_change=context.invalidate,
|
|
213
|
+
)
|
|
214
|
+
agent = AgentLoop(config, client, registry, context, tool_ctx, ui)
|
|
215
|
+
|
|
216
|
+
prompt = " ".join(args.prompt).strip()
|
|
217
|
+
if prompt:
|
|
218
|
+
return await ui.run_once(agent, prompt)
|
|
219
|
+
await ui.run_repl(agent, registry, lambda: run_doctor(config, client, ui))
|
|
220
|
+
return 0
|
|
221
|
+
finally:
|
|
222
|
+
await client.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv: list[str] | None = None) -> int:
|
|
226
|
+
args = build_parser().parse_args(argv)
|
|
227
|
+
if os.name == "nt":
|
|
228
|
+
for stream in (sys.stdout, sys.stderr):
|
|
229
|
+
try:
|
|
230
|
+
stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
|
231
|
+
except (AttributeError, ValueError):
|
|
232
|
+
pass
|
|
233
|
+
try:
|
|
234
|
+
return asyncio.run(_amain(args))
|
|
235
|
+
except KeyboardInterrupt:
|
|
236
|
+
return 130
|