Мы не ищем легких путей - захотелось запилить свой велосипед с черным 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 кодовых агентов
Комментарии (16)

dpvpro
14.05.2026 15:13"Claude Code — терминальный AI-ассистент к которому захотелось прикрутить Дипсик, но есть маленькая Проблема - он привязан к API Anthropic"
А зачем, когда есть OpenCode? Я прекрасно работаю с DeepSeek через него.

Granulex
14.05.2026 15:13Отличная реализация за вечер. Но есть нюанс, который автор не упомянул: эвристика "3 символа = 1 токен" работает для ASCII-кода, но для русского текста DeepSeek токенизирует примерно в 1,5–2 раза плотнее, а для китайского – иначе. Контекст может переполниться раньше, чем сработает суммаризатор. Правильнее считать токены через API или tiktoken, а не через длину строки.

okhsunrog
14.05.2026 15:13Сравнивали своё решение с https://github.com/Hmbown/DeepSeek-TUI ?

grand_inquisit0r Автор
14.05.2026 15:13не особо
Захотелось свой пильнуть :) (может мне нравится выбирать цвет фона и текста))
а если чуть серьезней, то еще хочется вставить туда свои инструменты оптимизации.

markoni
14.05.2026 15:13Тут либо неверен изначальный посыл (в первых предложениях), либо я не понял. Но что мешает через .claude/settings.json:
{ “env”:{“ANTHROPIC_BASE_URL”: “https://api.deepseek.com/anthropic”,“ANTHROPIC_AUTH_TOKEN”: “sk-xxxxxxxxxxxxxxxx”,“ANTHROPIC_MODEL”: “deepseek-v4-pro[1m]”,“ANTHROPIC_DEFAULT_OPUS_MODEL”: “deepseek-v4-pro[1m]”,“ANTHROPIC_DEFAULT_SONNET_MODEL”: “deepseek-v4-pro[1m]”,“ANTHROPIC_SMALL_FAST_MODEL”: “deepseek-v4-flash”,“ANTHROPIC_DEFAULT_HAIKU_MODEL”: “deepseek-v4-flash”,“CLAUDE_CODE_SUBAGENT_MODEL”: “deepseek-v4-flash”,“CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC”: “1”,“CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS”: “1”,“CLAUDE_CODE_ATTRIBUTION_HEADER”: “0”,“CLAUDE_CODE_EFFORT_LEVEL”: “max”}}

BlindOP
14.05.2026 15:13Claude Code — терминальный AI-ассистент к которому захотелось прикрутить Дипсик, но есть маленькая Проблема - он привязан к API Anthropic.
"deepseek claude code" в гугле и самый первый ответ (собственно на ресурсе deepseek) о том, как привязать claude code к deepseek.

alexanicus
14.05.2026 15:13Зачем? Потому что могу. И это достаточное основание. Статья полезная так как показывает типичные проблемы при создании своих агентов. Развивайте код дальше.

pilc80
14.05.2026 15:13Кстати, есть еще опция - использовать прокси и не пилить сам Claude Code, мне кажется, это гораздо юзабельнее, потому что можно обновлять Claude. Для себя я именно так сделал, если кому интересно, рад поделиться (под свободной лицензией): https://github.com/pilc80/claudex. Как будто бы весь функционал смог покрыть, кроме несовместимого Reasoning. Кстати, это только OpenAI, прикольно потом будет глянуть DeepSeek (в теории совместимо, но надо потрекать).
Будет круто, если вам зайдет, особенно если дадите обратную связь :)

ivvi
14.05.2026 15:13Тире в статье вместе дефисов греют душу!
И, судя по тому, что с грамматикой и пунктуацией швах, статья не прогонялась через ллм, а значит тире —"нативные". Респект )

WhiteBehemoth
Как альтернативу еще можно попробовать copilot cli (его можно привязать к любой Open Ai - совместимой LLM, если подписки на copilot нет).
grand_inquisit0r Автор
Но мы же не ищем легких путей)