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 ADDED
@@ -0,0 +1,4 @@
1
+ # Author: Tischenko A. (https://github.com/cruide)
2
+ """aisha — local console AI agent working with an external llama-server."""
3
+
4
+ __version__ = "0.2.2"
aisha/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # Author: Tischenko A. (https://github.com/cruide)
2
+ from aisha.cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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