|
Professor Seleznov
|
# Зачем это всё
Claude Code — терминальный AI-ассистент к которому захотелось прикрутить Дипсик, но есть маленькая Проблема - он привязан к API Anthropic. Естественно захотелось запилить свой велосипед с черным CMD и командами обеспечивающие ключевые концепции: tool use, permissions, memory, compaction, subagents — но с нуля, на чистом Node.js.Результат — deepseek-agent: ~2000 строк кода, 4 зависимости (openai, fast-glob, dotenv, @modelcontextprotocol/sdk), никаких фреймворков.Казалось бы, не столько сложно запилить своего агента, но есть нюансы.И так поехали С ходу пилим такую структуру:``index.js — точка входа, REPL
src/agent.js — agent loop
src/config.js — .agent/settings.json
src/memory.js —AGENT.md→ system prompt
src/permissions.js — alwaysAllow / neverAllow / [y/N]
src/hooks.js — PreToolUse / PostToolUse события
src/compactor.js — автосжатие контекста через LLM
src/mcp.js — подключение MCP-серверов
src/thinking.js — deepseek-reasoner (--think)
src/worktree.js — git worktree изоляция
src/output.js — JSON-режим для CI
src/ui.js — ANSI-цвета
src/tools/ — 9 инструментов`Ключевое решение: каждый инструмент — это объект с фиксированной структурой:`js
{
name: "read_file",
description: "Read the contents of a text file.",
parameters: { /* JSON Schema / },
isReadOnly: true, // false = нужно разрешение
async execute(args) {
return "результат строкой"
}
}` Добавить инструмент = написать объект и вставить его в массивTOOLS. Маршрутизация, JSON Schema для API, хуки, разрешения — всё подхватывается автоматически.
но чего-то не хватает, давай добавим сессии, команды, документацию
еще три коммита.Вынес систему команд (/clear,/compact,/diff,/review, ...) в отдельныйcommands.js. Добавилsession.js— сессии с чекпоинтами. Переписал README.
Здесь появилась важная абстракция: *команды и инструменты — разные вещи**. Команды (/clear,/rewind) — для пользователя. Инструменты (read_file,bash) — для модели. Команды могут вызыватьagentLoop(), но не наоборот.
Зарегистрировал глобальную командуagentчерезnpm linkи полеbinвpackage.json. Теперь вместоnpm start— простоagentиз любой директории.
Казалось бы все хорошо, но конечно же нет (а как ты хотел) Еще пол дня на полировку проекта.
Каждый коммит — конкретная проблема, вроде мелочи, а сильно портят картину.--- ## Архитектура### Agent Loop — сердце агентаВсё строится вокруг одного цикла вagent.js:`agentLoop(userMessage)
├─ pushMessage({ role: "user", content: userMessage })
└─ while(true)
├─ compactIfNeeded() — сжать контекст если > 80% лимита
├─ chat.completions.create({ stream: true })
│ ├─ собрать fullContent (текст ответа)
│ └─ собрать toolCalls (вызовы инструментов)
├─ finish_reason === "stop" → return
└─ finish_reason === "tool_calls"
└─ для каждого вызова:
├─ PreToolUse hook
├─ checkPermission()
├─ tool.execute(args)
├─ PostToolUse hook
└─ pushMessage({ role: "tool", result })`Модель сама решает, какой инструмент вызвать. Агент выполняет вызов и возвращает результат обратно в контекст. Цикл крутится, пока модель не ответитstop.DeepSeek API совместим с OpenAI — используется пакетopenaiс кастомнымbaseURL:`js
const client = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey: process.env.DEEPSEEK_API_KEY
})`### Стриминг: собираем tool_calls из дельтПри стриминге tool_calls приходят по частям. Имя функции и аргументы дробятся на чанки:`js
for await (const chunk of stream) {
if (delta?.tool_calls) {
for (const tc of delta.tool_calls) {
if (!toolCalls[tc.index]) {
toolCalls[tc.index] = {
id: "", type: "function",
function: { name: "", arguments: "" }
}
}
if (tc.id) toolCalls[tc.index].id +=tc.idif (tc.function?.name)
toolCalls[tc.index].function.name+=tc.function.nameif (tc.function?.arguments)
toolCalls[tc.index].function.arguments += tc.function.arguments
}
}
}`Ключевой момент:tc.indexопределяет, к какому tool_call относится дельта. Без этого нельзя корректно обработать параллельные вызовы.--- ## Инструменты: 9 штук, каждый — один файл### read_file — чтение с определением кодировкиНе простоfs.readFile. Определяем кодировку по BOM:`js
if (buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
return buf.slice(3).toString("utf-8") // UTF-8 BOM
}
if (buf[0] === 0xFF && buf[1] === 0xFE) {
return buf.slice(2).toString("utf16le") // UTF-16 LE
}`Бинарные файлы блокируются по расширению — без этого модель радостно пытается «прочитать» .png и .exe, тратя токены на мусор.### bash — песочницаБлокируем опасные паттерны по умолчанию:`js
const SANDBOX_BLOCKED = [
/\bcurl\b/, /\bwget\b/, // сеть
/\brm\s+-rf\s+\//, // деструктивные операции
/\bsudo\b/, /\bsu\b/ // привилегии
]`На Windows переключаем кодовую страницу в UTF-8 перед каждой командой:`js
const cmd = process.platform === "win32"
?chcp 65001 >nul 2>&1 & ${command}: command`Вывод обрезается до 8000 символов — без этого одинcatна большой файл съест весь контекст.### edit_file — точная замена строкВместо line-based diff — exact string replacement. Модель передаётold_stringиnew_string. Если строка встречается больше одного раза — ошибка:`js
const count = original.split(old_string).length - 1
if (count > 1) {
returnError: old_string found ${count} times — make it more specific}`Красивый diff с ANSI-подсветкой — удалённые строки на тёмно-красном фоне, добавленные на зелёном, с 3 строками контекста.### web_search — DuckDuckGo без API ключаПарсим HTML DuckDuckGo напрямую — никакого API ключа не нужно:`js
const url =https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}const html = await fetch(url).then(r => r.text())
// Извлекаем результаты регуляркой
const resultRegex = /<a[^>]+class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)<\/a>...`### task — субагентыРекурсивный вызовagentLoop()— субагент получает свой контекст и работает независимо:`js
// Параллельно
const results = await Promise.all(parallel.map(desc => agentLoop(desc))
) // Фоново
const entry = { done: false, result: null }
entry.promise = agentLoop(description).then(result => {
entry.done = true
entry.result = result
})`Для инициализации используется инъекция:initTaskTool(agentLoop). Это решает проблему циклической зависимости —task.jsне импортируетagent.js.### todo — задачи с зависимостямиВнутрисессионный трекер задач. ПоддерживаетblockedBy— задача не может перейти вin_progress, пока зависимости не завершены:`js
if (status === "in_progress" && isBlocked(todo)) {
const blocking = todo.blockedBy.filter(
depId => getTodo(depId)?.status !== "done"
)
returnCannot start #${id} — blocked by: ${blocking.join(", ")}}`--- ## Система разрешенийТри уровня:
1. alwaysAllow — выполняется без вопросов (read_file,glob,grep)
2. neverAllow — заблокировано навсегда
3. Интерактивный запрос — для всего остальногоДля файловых операций — запрос на уровне директории:`┌ [?] write_file → src/utils.js
└ [y] один раз [d] запомнить папку "src" [N] отклонить:`Нажалd— папка сохраняется в.agent/settings.json. Следующий раз не спросит.Дляbash— запрос на уровне инструмента:`┌ [?] bash: {"command":"npm test"}
└ [y] один раз [a] запомнить для проекта [N] отклонить:`Нажалa— bash добавляется вalwaysAllowв конфиге.--- ## Компактор: бесконечный контекст через суммаризациюПроблема: у DeepSeek контекстное окно ограничено. После 10–15 ходов контекст переполняется.Решение: перед каждым запросом к API проверяем размер контекста. Если > 80% лимита — суммаризируем всю историю через ту же модель:`js
if (!force && tokens < contextLimit 0.8) return messages // Оставляем system prompt, суммаризируем остальное
const summaryResponse = awaitclient.chat.completions.create({
model: getModel(),
messages: [
{ role: "system", content: "Summarize the conversation..." },
{ role: "user", content:rest.map(m =>[${m.role}]: ${m.content}).join("\n") }
]
}) return [system, { role: "user", content:[Summary]:\n${summary}},
{ role: "assistant", content: "Understood." }]`Оценка токенов — грубая, но работает: ~3 символа = 1 токен. Base64-изображения считаются по длине строки.--- ## MCP — подключай чужие инструментыModel Context Protocol — стандарт от Anthropic для подключения внешних инструментов. Конфиг в.agent/settings.json:`json
"mcpServers": {
"fs": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path"]
}
}`При старте агент подключается к серверу через stdio, получает список инструментов и регистрирует их с префиксомmcp__<server>__<tool>:`js
const transport = new StdioClientTransport({
command: cfg.command, args: cfg.args ?? []
})
const client = new Client({ name: "deepseek-agent", version: "0.1.0" })
await client.connect(transport)
const { tools: serverTools } = await client.listTools()`Инструменты MCP проходят через ту же систему разрешений.--- ## Память:AGENT.mdТри уровня памяти, все загружаются в system prompt:| Файл | Назначение |
|---|---|
|~/.agent/AGENT.md| Глобальные инструкции (стиль, предпочтения) |
|.agent/AGENT.md| Инструкции для проекта (архитектура, стек) |
|AGENT.md| Инструкции в корне репо | Это аналогCLAUDE.mdв Claude Code. Модель видит эти инструкции в каждом диалоге.--- ## Хуки: интеграция со своими скриптами.agent/hooks.jsonпозволяет запускать shell-команды на события агента:`json
{
"PreToolUse": [{ "command": "cat >> agent.log" }],
"PostToolUse": [],
"Stop": []
}`PreToolUseс ненулевым exit code блокирует выполнение инструмента. Payload приходит через stdin как JSON — можно фильтровать по имени инструмента, аргументам.--- ## Слеш-команды: 17 штукВсё управление — через/-команды в чате:-/clear— сбросить контекст
-/compact— принудительно сжать контекст
-/context— прогресс-бар заполненности контекста
-/btw <вопрос>— вопрос без добавления в историю
-/rewind— откат к чекпоинту (автоматически создаются каждый ход)
-/review— отправитьgit diffна ревью
-/security-review— анализ безопасности
-/simplify— три параллельных агента: DRY, качество, производительность
-/batch <задача>— агент декомпозирует задачу и выполняет параллельно
-/loop 5m <промпт>— периодический запуск (аналог cron)
-/resume— восстановить предыдущую сессию
-/export— сохранить диалог в файл/simplify— пример мощи субагентов. Три агента запускаются параллельно черезPromise.all, каждый анализирует изменённые файлы под своим углом:`js
const tasks = [
"Review for code reuse opportunities and DRY violations...",
"Review for code quality: naming, complexity, readability...",
"Review for performance and efficiency issues..."
]
await taskTool.execute({ parallel: tasks })`--- ## Проблемы, которые пришлось решать### Прожорливость по токенамКоммитfa04583: модель читала файлы целиком и вставляла их в контекст. Решение — обрезка результатов инструментов:`js
const CONTEXT_LIMIT = 12000
const toolContent = full.length > CONTEXT_LIMIT
? full.slice(0, CONTEXT_LIMIT) +\n[... truncated, ${full.length - CONTEXT_LIMIT} chars omitted]: full`Вывод bash тоже ограничен: 8000 символов.### Бинарные файлыКоммит56116a1: модель лезла в .exe, .png, .zip без спроса. Добавил блокировку бинарных расширений вread_fileи исключения бинарников вgrep.### Windows: кодировкаКоммитa5193ac: на Windowsstdoutпо умолчанию использует cp1251. Русский текст превращался в кракозябры. Решение:chcp 65001перед каждой командой bash и BOM-детекция вread_file.### Файлы валятся в консольКоммит499a9ff: когда модель читала файл, его содержимое выводилось в терминал целиком. ДобавилformatToolResult()— дляread_fileвыводит только «42 строки, 1200 символов», а не весь файл.### Разрешение на папкуКоммит028d4e4: при первой записи в файл агент спрашивал разрешение. При второй — снова. Добавил механизмapprovedDirs— одобряешь папку, и все файлы в ней пишутся без вопросов.### Персистентность сессийКоммит86e21e0: при закрытии терминала вся история терялась. Добавил автосохранение в.agent/session.jsonпри выходе и/resumeдля восстановления.--- ## Что под капотом: зависимостиВсего 4 пакета:| Пакет | Зачем |
|---|---|
|openai| Клиент DeepSeek API (совместим с OpenAI) |
|fast-glob| Поиск файлов по паттернам вglobиgrep|
|dotenv| Загрузка.env|
|@modelcontextprotocol/sdk| Клиент MCP | Никаких chalk, inquirer, commander, yargs. ANSI-цвета — 6 строк. CLI-парсинг —process.argv.slice(2). readline — встроенныйnode:readline.--- ## Переключение моделейБлагодаря OpenAI-совместимому API, агент работает не только с DeepSeek:`DeepSeek baseURL:https://api.deepseek.commodel: deepseek-chat
OpenAI без baseURL model: gpt-4o
Ollama baseURL:http://localhost:11434/v1model: qwen2.5-coder
Groq baseURL:https://api.groq.com/openai/v1model: llama-3.3-70b-versatile`МеняешьbaseURLв коде иmodelв конфиге — готово.--- ## Режим extended thinkingФлаг--thinkпереключает наdeepseek-reasoner. Эта модель возвращаетreasoning_contentотдельно от ответа — внутренний chain-of-thought:`js
export function printReasoning(chunk) {
const delta = chunk.choices[0]?.delta
if (delta?.reasoning_content) {
process.stdout.write(c.dim(delta.reasoning_content))
return true
}
return false
}`В терминале reasoning выводится приглушённым цветом между маркерами[thinking]/[/thinking].--- ## JSON-режим для CI`bash
agent --output-format=json "что делает index.js?"`Каждое событие — отдельная JSON-строка:`json
{ "type": "text", "text": "фрагмент ответа" }
{ "type": "tool_call", "tool": "bash", "args": { "command": "ls" } }
{ "type": "tool_result", "tool": "bash", "result": "file1.js" }`Обычный вывод подавляется — функцияprint()ничего не делает в JSON-режиме:`js
export function print(text) {
if (_format !== "json") process.stdout.write(text)
}`--- ## Итоги*Что получилось:**
- ~2000 строк JavaScript (ES modules)
- 27 коммитов за неделю
- 4 зависимости, ноль фреймворков
- 9 инструментов + MCP для расширения
- Система разрешений с персистентностью
- Автокомпакция контекста
- Субагенты (синхронные, параллельные, фоновые)
- 17 слеш-команд
- Хуки, память, сессии, git worktree
- Работает на Windows, Linux, macOS**Что я вынес:** 1. OpenAI SDK — универсальный клиент. DeepSeek, Groq, Ollama — все говорят на одном протоколе. Один пакет покрывает всех.2. Tool use — это просто. JSON Schema описывает параметры, модель сама решает когда вызывать. Не нужно парсить текст, искать команды в ответе — API всё делает.3. Стриминг tool_calls — единственная сложность. Дельты приходят по частям, нужно склеивать по индексу. Но когда разберёшься — это 15 строк кода.4. Контекст — главный ресурс. 80% багов были про «модель съела слишком много токенов». Обрезка результатов, блокировка бинарников, компактор — всё ради экономии контекста.5. Минимализм работает. Без фреймворков проще понимать, что происходит. ANSI-цвета за 6 строк вместо chalk.process.argv` вместо yargs. readline вместо inquirer.Весь код — [на GitHub](https://github.com/skydeex/deepseekAgent). В следующей статье я напишу как я запилил оптимизатор расхода токенов для ai кодовых агентов-Источник
|