Мы не ищем легких путей - захотелось запилить свой велосипед с черным CMD и командами обеспечивающие ключевые концепции: tool use, permissions, memory, compaction, subagents — но с нуля, на чистом Node.js.

Update шепотки уникальности\полезности
Добавил оптимизатор потребления токенов, сокращает их затраты на 70% при работе с готовым проектом
пока немного языков, позже докину еще


Результат — 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/ — инструментов

`

Ключевое решение: каждый инструмент — это объект с фиксированной структурой:
`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.id
if (tc.function?.name)
toolCalls[tc.index].function.name += tc.function.name
if (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, тратя токены на мусор.
Блокируем опасные паттерны по умолчанию:
`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) {
return Error: 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.
Внутрисессионный трекер задач. Поддерживает blockedBy — задача не может перейти в in_progress, пока зависимости не завершены:
`js
if (status === "in_progress" && isBlocked(todo)) {
const blocking = todo.blockedBy.filter(
depId => getTodo(depId)?.status !== "done"
)
return Cannot 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 = await client.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 проходят через ту же систему разрешений.

Память:
Три уровня памяти, все загружаются в system prompt:
~/.agent/AGENT.md - Глобальные инструкции (стиль, предпочтения)
.agent/AGENT.md - Инструкции для проекта (архитектура, стек)
AGENT.md - Инструкции в корне репо


Хуки:
интеграция со своими скриптами.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 })
`


Проблемы, которые пришлось решать
Прожорливость по токенам модель читала файлы целиком и вставляла их в контекст. Решение — обрезка результатов инструментов:
`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 символов.
- модель лезла в .exe, .png, .zip без спроса. Добавил блокировку бинарных расширений в read_file и исключения бинарников в grep.
- когда модель читала файл, его содержимое выводилось в терминал целиком. Добавил formatToolResult() — для read_file выводит только «42 строки, 1200 символов», а не весь файл.
- Разрешение на папку. при первой записи в файл агент спрашивал разрешение. При второй — снова. Добавил механизм approvedDirs — одобряешь папку, и все файлы в ней пишутся без вопросов.
- Персистентность сессий. при закрытии терминала вся история терялась. Добавил автосохранение в .agent/session.json при выходе и /resume для восстановления.


Никаких chalk, inquirer, commander, yargs. ANSI-цвета — 6 строк. CLI-парсинг — process.argv.slice(2). readline — встроенный node:readline.

Переключение моделей
Благодаря OpenAI-совместимому API, агент работает не только с DeepSeek:`
DeepSeek baseURL: https://api.deepseek.com model: deepseek-chat
OpenAI без baseURL model: gpt-4o
Ollama baseURL: http://localhost:11434/v1 model: qwen2.5-coder
Groq baseURL: https://api.groq.com/openai/v1 model: 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
}
`


Обычный вывод подавляется — функция 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 кодовых агентов