Мы не ищем легких путей - захотелось запилить свой велосипед с черным 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 с кастомным baseURLjsconst client = new OpenAI({ baseURL: "https://api.deepseek.com", apiKey: process.env.DEEPSEEK_API_KEY})
`
Стриминг: собираем tool_calls из дельт. При стриминге tool_calls приходят по частям. Имя функции и аргументы дробятся на чанки:
`jsfor 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:
`jsif (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, тратя токены на мусор.
Блокируем опасные паттерны по умолчанию:
`jsconst SANDBOX_BLOCKED = [ /\bcurl\b/, /\bwget\b/, // сеть /\brm\s+-rf\s+\//, // деструктивные операции /\bsudo\b/, /\bsu\b/ // привилегии]
`
На Windows переключаем кодовую страницу в UTF-8 перед каждой командой:
`jsconst 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. Если строка встречается больше одного раза — ошибка:
`jsconst count = original.split(old_string).length - 1if (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, пока зависимости не завершены:
`jsif (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% лимита — суммаризируем всю историю через ту же модель:
`jsif (!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>:
`jsconst 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, каждый анализирует изменённые файлы под своим углом:
`jsconst 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-chatOpenAI без baseURL model: gpt-4oOllama baseURL: http://localhost:11434/v1 model: qwen2.5-coderGroq 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:`jsexport 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-режиме:`jsexport 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 кодовых агентов
