Языковая модель (LLM) умеет одно: продолжать текст. Задали вопрос - она дописала ответ. AI-агент появляется, когда вокруг модели собирают обвязку (harness) - модель просит что-то вызвать, а harness берёт вызов на себя. Может спросить подтверждение у человека, может отсечь действие защитными правилами (guardrails).

Мощность этой обвязки часто важнее, чем кажется. Системные инструкции, память, цикл вызовов и набор встроенных инструментов (tools) меняют и то, что агент реально умеет, и качество результата. Есть исследования, где на одних и тех же моделях смена harness ведет к улучшению результата. Cursor или OpenRouter иногда обходят родной harness Anthropic на его же моделях. Отсюда же разрыв между ценой подписки и ценой API.

Важная часть агента - доступные ему инструменты. Они живут рядом с агентом или приезжают с MCP-сервера. Нужное действие - отдельный инструмент. Поиск по CRM - search_customer, отправка письма - send_email. MCP сделал такую интеграцию переносимой, т.к. владелец сервиса публикует инструменты, а владелец агента подключает его, не влезая во внутренности реализации.

Сценарии для моделей становятся все сложнее, количество вызовов tools все больше, цепочки вызовов tools становятся длиннее, но это не бесплатно - каждый вызов tools - это история из запроса и ответа в контексте модели, каждый раз на все это модель смотрит и решает, что с этим делать, и сложность принятия решения увеличивается по мере увеличения контекста. При этом, с точки зрения классического IT, часть логики вообще примитивная, и с ней справился самый простой скрипт лучше, быстрее и дешевле.

Очевидное решение - дать модели писать нужный ей код, заменяющий часть размышлений, и выполнять его. В общем виде для инженеров это звучит страшновато, ведь модель может написать и запустить то, что вам бы очень не хотелось никогда запускать на вашем компьютере/сервере: удаление важных файлов, отправка конфиденциальных данных в интернет и т.д. Это может быть из-за ошибки модели, вольной интерпретации просьбы (человек сказал удали это, но модель поняла по-своему, что значит это), внедрения инструкций через prompt injection со страницы или из документа, который модель только что прочитала. Поэтому перед тем, как дать модели запускать производный код, приходится тратить время и ресурсы на изолированную песочницу (sandbox). В LangChain для этого есть внешние провайдеры - код уезжает на их серверы, где изолированно запускается. Есть возможность вынести sandbox в отдельные Docker-контейнеры, где доступ к интернету или хосту вы настроили как требуется вам. Есть более «виртуальные» песочницы на вашей машине - они спрашивают подтверждение у человека на подозрительные действия, но по факту это часто перекладывание ответственности. После десятого запроса одиннадцатое пользователи жмут не глядя или включают автоподтверждение.

Более современное решение - выделить класс задач для автоматизации логики LLM и взять для них самую легкую, простую и ограниченную песочницу - JavaScript-интерпретатор. Это позволяет закрыть большое количество сценариев, существенно уменьшая риски. Модель получает возможность реализовать недостающий кусок логики, составить цепочку вызовов инструментов (сами вызовы по-прежнему делает harness, но запросы и ответы можно детерминированно отфильтровать и склеить) и задать процедуру для субагентов.

В этой статье разберу как такие интерпретаторы могут помогать. Начну с примера, который можно повторить у себя в LM Studio, а потом перейду к более общему устройству на LangChain, где простой JS-интерпретатор становится уже не таким простым, а забирает часть логики с LLM и помогает ей фокусироваться на том, что нужно, делегируя детерминированную логику скрипту.

Зачем агенту возможность писать скрипты - простой пример на LM Studio

Посмотрим на LM Studio - современное приложение, позволяющее локально скачивать современные небольшие модели из каталога и чатиться с ними. Есть и аналоги, но LM Studio - один из самых простых - все в одном UI-интерфейсе: понятный каталог моделей с оценкой, что ваш компьютер реально потянет, возможность скачать модель в два клика мышкой, сам устанавливает нужные движки выполнения моделей, предоставляет чат-интерфейс, возможность подключать MCP, публиковать модель через веб-интерфейс для доступа из других приложений и т.д.

Берем новый чат, загружаем модель и спрашиваем какое сегодня число?

LM Studio: без инструментов модель отвечает датой из обучения
LM Studio: без инструментов модель отвечает датой из обучения

Модель утверждает, что сегодня 24 июня 2026 года, но это не так. Это дата, которая осталась в модели в момент ее тренировки. У большинства моделей есть какое-то представление о дате, застрявшее при обучении, но часто есть tools, которые модель перед вопросами о дате спрашивает, и именно поэтому модели отвечают текущей датой, а не застрявшей датой из памяти. В нашем случае у модели нет ни одного tool, так что шансов ответить правильно у нее не было.

В LM Studio из коробки есть plugin - js-code-sandbox, с одним-единственным инструментом - run_javascript, который позволяет модели запускать произвольный JavaScript/TypeScript-код в изолированном in-process sandbox без доступа к сети. Включим его в правой панели и повторим наш запрос какое сегодня число?. В этот раз при размышлении модель решила, что для этого ей надо сделать простой скрипт и выполнить его, а после этого на основе результата модель ответила правильно:

LM Studio: с run_javascript инструментом (tool) модель отвечает правильной текущей датой
LM Studio: с run_javascript инструментом (tool) модель отвечает правильной текущей датой

Выглядит достаточно сложно для простого вопроса, но чтобы модель узнала текущую дату, ей нужно, чтобы кто-то ей ее назвал. Мы ничего не писали сами, мы даже не говорили модели в prompt, как вообще можно узнать дату. Модель знает, что она может написать скрипт на javascript, и знает, что можно с помощью него сделать. Мы могли бы сами написать tool - current_date, это бы дало аналогичный эффект, но тут модель сама до всего додумалась.

Немного усложним пример. Допустим, вам нужно получить какую-то справку, которую можно получить только в третий четверг после полнолуния. Спросим нашу модель, которая может писать код: когда будет третий четверг после ближайшего полнолуния.

Модель немного задумалась, но написала код для расчета текущей даты, ближайшего полнолуния, вывода четвергов после этой даты и вернулась с ответом.

LM Studio: с run_javascript инструментом (tool) модель считает третий четверг после полнолуния
LM Studio: с run_javascript инструментом (tool) модель считает третий четверг после полнолуния

Для решения этой задачи модель сама решила, что ей надо написать и выполнить следующий скрипт.

// Get current date
const now = new Date();
console.log("Сейчас (UTC):", now.toISOString());

// --- Find next full moon using synodic month calculation ---
const synodic = 29.530588853; // days
const newMoonEpochMs = Date.UTC(2000, 0, 6, 18, 14); // known new moon
const startMs = now.getTime();
const startDays = startMs / 86400000;
const epochDays = newMoonEpochMs / 86400000;

// full moons occur at (i + 0.5) * synodic after epoch
let i = Math.floor((startDays - epochDays) / synodic + 0.5);
while ((epochDays + (i + 0.5) * synodic) * 86400000 <= startMs) i++;
const fullMoonMs = (epochDays + (i + 0.5) * synodic) * 86400000;
const fmUtc = new Date(fullMoonMs);
console.log("Ближайшее полнолуние (UTC):", fmUtc.toISOString());

// Full moon date in Moscow time (UTC+3)
const fmMoscow = new Date(fullMoonMs + 3 * 3600000);
const fmDateStr = fmMoscow.toISOString().slice(0, 10);
console.log("Дата полнолуния (МСК):", fmDateStr);

// --- Third Thursday strictly after the full moon date ---
const fmDay = new Date(Date.parse(fmDateStr + "T00:00:00Z"));
const thursdays = [];
let d = new Date(fmDay.getTime() + 86400000); // day after full moon
while (thursdays.length < 3) {
  if (d.getUTCDay() === 4) thursdays.push(d.toISOString().slice(0, 10));
  d = new Date(d.getTime() + 86400000);
}
console.log("Четверги после полнолуния:", thursdays);
console.log("Третий четверг:", thursdays[2]);

Проверил в одной известной публичной модели, она ответила, что есть вопросы к этому скрипту, т.к. это линейная аппроксимация, а не астрономический расчет фаз Луны, но с датой согласилась, что мне кажется успешный успех.

Если бы модель пробовала вычислить полнолуние и следующие четверги сама, то это было бы приличной цепочкой внутренних рассуждений с большой вероятностью ошибки на любом из этапов - скрипт это считает быстрее и надежнее. Есть огромный пласт задач, где скрипт работает лучше - это базовая арифметика, операции с датами, различные фильтры текста или JSON (который, допустим, получен из другого tool), операции со списками, справочниками и т.д.

Допустим, из таблицы или из другого tool в чат вернулись шесть сумм с размерами платежей, а дальше надо из этого вывести в отчете количество платежей, общую сумму, средний размер и 95-й перцентиль. Модель все расчеты может свести к вот такому скрипту, и это точно будет быстрее, чем долгие размышления внутри модели, при этом через скрипт это будет надежнее:

const amounts = [1200, 3500, 700, 8100, 940, 1250];
const sorted = [...amounts].sort((a, b) => a - b);
const total = amounts.reduce((sum, value) => sum + value, 0);
const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1];

console.log({
  count: amounts.length,
  total,
  average: total / amounts.length,
  p95,
});

Нам не пришлось писать tools calculator, sum_payments и percentile. Один интерпретатор делает такие вычисления возможными за счет одного простого tool для модели, а синтаксис и возможности javascript модель и так знает. Скрипт с ошибкой способен зависнуть или съесть память, поэтому нужны таймаут и лимит памяти. Но такой скрипт не прочитает ~/.ssh, не отправит архив на сторонний сервер и не удалит важные данные на диске. Если говорить именно про js-code-sandbox, то он на самом деле имеет доступ к файловой системе в рамках одной папки, которая создается под конкретный чат. Т.е. скрипт может создать файл в этой папке, и прочитать его позже, но вот другой чат будет в другой папке и будет видеть только свои файлы.

Использование JavaScript в LangChain

Если перейти от инструментов, доступных обычным пользователям, к инструментам для разработчиков, создающих своих агентов, то тут самый распространенный framework - LangChain. Можно собирать нужные компоненты, подключать инструменты, разворачивать на собственных серверах и делать кастомные интеграции в рабочие процессы. LangChain - это база для создания AI-агентов. Также у LangChain есть отдельная библиотека deepagents с эталонным Harness для создания современных продвинутых агентов с поддержкой skills, todo, файловой системы, полноценных sandbox (локальных и удаленных) для python. В рамках нее существует библиотека langchain_quickjs, где есть класс CodeInterpreterMiddleware, предоставляющий агенту инструмент eval на базе quickjs, но его возможности гораздо шире, чем в нашем сценарии из LM Studio. Давайте разберем его подробнее.

При загрузке блока CodeInterpreterMiddleware в системный промпт агента добавляется определенный текст.

Его собирает render_repl_system_prompt в исходнике langchain_quickjs/_prompt.py. Ниже вариант по умолчанию: timeout=5.0, memory_limit=64 MB, mode="thread".

### Interpreter

An `eval` tool is available. It runs JavaScript in a persistent REPL.

- State (variables, functions) persists across tool calls and across multiple turns for this conversation thread.
- Top-level `await` works; Promises resolve before the call returns.
- Runtime sandbox: no built-in filesystem, network, stdlib, or wall-clock APIs (`fetch`, `require`, `fs`, `process`, real `Date.now()` are unavailable or stubbed).
- The REPL has no access to host tools, files, or the network: it is pure computation. Return values to communicate results.
- Timeout: 5.0s per call. Memory: 64 MB total.
- `console.log` output is captured and returned alongside the result.

На самом деле это не все, если включить дополнительные опции, то будет добавлено несколько достаточно крупных разделов, но это мы разберем чуть позже. Из интересного, в промпт явно указан запрет на использование текущей даты скриптом, так что наши первые примеры из LM Studio не будут пытаться использовать скрипт для такой цели. Тут у разработчиков из LangChain есть своя логика, и они её придерживаются - интерпретатор должен быть capability-scoped execution layer, т.е. должен управлять явно указанными ему инструментами (tools). Получается, что возможность получить дату - это создание нового инструмента, а они должны задаваться явно, а не динамически. Именно поэтому разработчики явно запрещают пользоваться текущей датой в своих скриптах. Это декларативное ограничение, на самом деле, если вы в своем prompt разрешаете, то все будет работать, но при использовании библиотеки лучше следовать логике её создателей, иначе это может привести к будущим конфликтам, которые будет очень сложно отлаживать.

Давайте начнем с простого агента на базе LangChain (пока основные возможности продвинутого deepagents нам не нужны) и добавим ему возможность использования quickjs. Для этого агенту надо передать дополнительный CodeInterpreterMiddleware, реализующий этот функционал:

from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_quickjs import CodeInterpreterMiddleware

model = init_chat_model(
    "qwen/qwen3.8-27b",
    model_provider="openai",
    base_url="http://127.0.0.1:1234/v1",
    api_key="x",
)

agent = create_agent(
    model=model,
    middleware=[
        CodeInterpreterMiddleware(          # данный middleware сам добавит инструмент `eval` и обновит prompt
            timeout=5.0,                    # лимит времени на один вызов eval, секунды
            memory_limit=64 * 1024 * 1024,  # потолок кучи интерпретатора, байты
        ),
    ],
)

Теперь попробуем спросить у модели наш последний пример.

result = agent.invoke(
  {"messages": [
    "вот платежи 1200, 3500, 700, 8100, 940, 1250. "
    "сколько всего прошло, какой средний чек и какой p95? "
  ]}
)

Напечатаем развернуто историю диалога, особо обращая внимание на детали tool_call (вызов tool):

for msg in result["messages"]:
    print(getattr(msg, "type", type(msg).__name__))
    for tc in getattr(msg, "tool_calls", None) or []:
        name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
        args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)
        print(f"tool_call: name='{name}' args:")

        for arg_name, arg_value in args.items():
            print(f"{arg_name}:")
            print(arg_value)

    content = getattr(msg, "content", None)
    if content:
        print(content)
        
    print("-" * 40)

Видим, что для расчета модель сама сформировала javascript, выполнила его и написала ответ на основе его результатов.

human
вот платежи 1200, 3500, 700, 8100, 940, 1250. сколько всего прошло, какой средний чек и какой p95? 
----------------------------------------
ai
tool_call: name='eval' args:
code:
const p = [1200, 3500, 700, 8100, 940, 1250];
const sum = p.reduce((a,b)=>a+b,0);
const avg = sum/p.length;
const s = [...p].sort((a,b)=>a-b);
// nearest-rank p95
const nr = s[Math.ceil(0.95*p.length)-1];
// linear interpolation (numpy default)
const idx = 0.95*(s.length-1);
const lo = Math.floor(idx), hi = Math.ceil(idx);
const lin = s[lo] + (idx-lo)*(s[hi]-s[lo]);
console.log({sum, avg, sorted: s, nearestRank: nr, linear: lin});
----------------------------------------
tool
<stdout>
{sum: 15690, avg: 2615, sorted: [700, 940, 1200, 1250, 3500, 8100], nearestRank: 8100, linear: 6950}
</stdout>
<result>null</result>
----------------------------------------
ai
По данным 6 платежей (700, 940, 1200, 1250, 3500, 8100):

- **Всего прошло:** 15 690
- **Средний чек:** 2 615
- **P95:** зависит от метода расчёта:
  - метод ближайшего ранга (наиболее распространённый для малых выборок): **8 100** — это просто максимальный платёж, т.к. 0.95 × 6 = 5.7 → округляем до 6-го элемента отсортированного списка;
  - линейная интерполяция (по умолчанию в numpy): **6 950**.

Для такого маленького выборки p95 почти совпадает с максимумом, так что более осмысленной метрикой был бы медианный чек — **1 225**.
----------------------------------------

LangChain и Programmatic Tool Calling (PTC) или разрешенная магия вне Хогвардса

Если нужен вариант с использованием внутри скрипта текущей даты, то, как говорил раньше, правильный способ в LangChain все-таки указать ее отдельным tools, а не переопределять правила системы в своем prompt (хотя технически это возможно). На первый взгляд кажется, что мы потеряем половину преимуществ, т.к. теперь модели сначала надо отправить запрос в инструмент (tool), чтобы узнать текущую дату, а потом уже следующий запрос на использование инструмента (tool) eval на базе JS, но именно тут раскрывается магия библиотеки deepagents - в ней есть режим, когда именно скрипт может вызвать tools. Это вообще-то немного расходится с оригинальным каноном агентского цикла (ReAct), когда инструмент (tool) вызывается на основе запроса модели и она же решает, что делать с результатом этого вызова на следующем ходу. Тут модель решает вызвать инструмент eval, делегируя ему вызов нужного количества инструментов (tools) и обработку результатов. Такой подход называется programmatic tool calling, или PTC. Разработчику надо явно указать инструменты (tools), которые код увидит внутри пространства tools, в параметре ptc конструктора класса CodeInterpreterMiddleware. Переданные tools пробрасываются внутрь JavaScript с заменой имен на camelCase: get_current_datetime превращается в tools.getCurrentDatetime(...). Сами вызовы по-прежнему делает harness на MCP, скрипт только просит сделать вызов и обрабатывает ответы.

Разберем это на нашем простом примере, а потом я покажу, когда это реально все меняет и забирает часть логики с LLM.

Создадим наш простой tool для определения текущего времени и даты в виде python-функции:

from datetime import datetime, timezone
from langchain.tools import tool

@tool
def get_current_datetime() -> str:
    """Return the current date and time in UTC as an ISO 8601 string."""
    return datetime.now(timezone.utc).isoformat()

Создадим агента и явно укажем, что наш интерпретатор может вызывать tool get_current_datetime, если он ему нужен. Заметьте, мы предоставили этот tool именно интерпретатору, а не самому агенту (так тоже можно, если нужно, чтобы модель могла его вызвать):

agent = create_agent(
    tools=None,                             # Самому агенту в нашем примере get_current_datetime не будет доступен
    model=model,
    middleware=[
        CodeInterpreterMiddleware(          # данный middleware добавит инструмент `eval` и обновит system prompt
            ptc=[get_current_datetime],
            timeout=5.0,                    # лимит времени на один вызов eval, секунды
            memory_limit=64 * 1024 * 1024,  # потолок кучи интерпретатора, байты
        ),
    ],
)

Отправляем модели наш старый знакомый запрос:

result = agent.invoke({"messages": ["когда будет третий четверг после ближайшего полнолуния?"]})

Напечатаем развернуто историю диалога:

for msg in result["messages"]:
    print(getattr(msg, "type", type(msg).__name__))
    for tc in getattr(msg, "tool_calls", None) or []:
        name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", None)
        args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", None)
        print(f"tool_call: name='{name}' args:")

        for arg_name, arg_value in args.items():
            print(f"{arg_name}:")
            print(arg_value)

    content = getattr(msg, "content", None)
    if content:
        print(content)
        
    print("-" * 40)

Обратите внимание на const now = await tools.getCurrentDatetime({}); внутри сгенерированного скрипта - это и есть tool_call нашей python функции get_current_datetime, сделанный из скрипта

human
когда будет третий четверг после ближайшего полнолуния?
----------------------------------------
ai
tool_call: name='eval' args:
code:
const now = await tools.getCurrentDatetime({});
console.log("Current:", now);

// Find the next full moon using a known new moon reference and the synodic month
// Known new moon: 2000-01-06 18:14 UTC (JD 2451550.26)
const SYNODIC = 29.530588853;

const newMoonRef = Date.UTC(2000, 0, 6, 18, 14);
const msPerDay = 86400000;

const nowMs = Date.parse(now);
const daysSinceRef = (nowMs - newMoonRef) / msPerDay;

// Number of synodic months since reference
const n = Math.floor(daysSinceRef / SYNODIC);

// Next full moon: we need the full moon (moon age = SYNODIC/2) after now
// Full moon phase offset from new moon = SYNODIC / 2
let fullMoonMs = newMoonRef + (n * SYNODIC + SYNODIC / 2) * msPerDay;
if (fullMoonMs < nowMs) {
  fullMoonMs += SYNODIC * msPerDay;
}

const fullMoonDate = new Date(fullMoonMs);
console.log("Next full moon:", fullMoonDate.toISOString());

// Find the first Thursday after the full moon
let d = new Date(fullMoonMs);
// Thursday = 4 in JS getUTCDay (0=Sun, 1=Mon, ..., 4=Thu)
while (d.getUTCDay() !== 4) {
  d.setUTCHours(d.getUTCHours() + 24);
}
console.log("First Thursday after full moon:", d.toISOString());

// Third Thursday = first Thursday + 14 days
const thirdThursday = new Date(d.getTime() + 14 * msPerDay);
console.log("Third Thursday after full moon:", thirdThursday.toISOString());
----------------------------------------
tool
<stdout>
Current: 2026-08-18T20:30:50.790147+00:00
Next full moon: 2026-08-28T02:07:47.938Z
First Thursday after full moon: 2026-09-03T02:07:47.938Z
Third Thursday after full moon: 2026-09-17T02:07:47.938Z
</stdout>
<result>null</result>
----------------------------------------
ai
**Ближайшее полнолуние** — 28 августа 2026 г. (пятница).

**Третий четверг после него — 17 сентября 2026 года.**

| # | Дата |
|---|------|
| 1-й четверг | 3 сентября 2026 |
| 2-й четверг | 10 сентября 2026 |
| 3-й четверг | **17 сентября 2026** |
----------------------------------------

Ура. Мы сделали сложный расчет с помощью JS за пределами модели, а модель сгенерировала код. Мы сделали это согласно логике авторов LangChain, что скрипт управляет только тем, что ему явно дали, в нашем случае мы явно дали ему возможность узнавать текущее время на сервере. При этом мы написали для этого функцию на python, но вызвалась она из JS - harness решил это за нас.

А теперь к примеру, где это может сильно повлиять на скорость, качество и цену конечного решения.

Допустим, наш агент может работать с CRM. CRM у нас имеет свой MCP (либо от производителя, либо API простой и сами за вечер написали). Допустим, там есть функции: search_customer - поиск клиентов по параметрам (город, менеджер, имя) get_customer - карточка клиента по идентификатору list_deals - сделки клиента list_next_actions - следующие действия: созвоны, задачи, письма list_meetings - встречи по клиенту или сделке

Допустим, наш агент получает вопрос от сотрудника отдела продаж в корпоративном чате: С кем из заказчиков в Самаре надо созвониться или встретиться на следующей неделе.

В классическом агентном цикле (ReAct) эта задача будет решаться последовательностью вызовов tools моделью:

  • модель формирует запрос к search_customer - поиск клиентов менеджера по фильтру город Самара

  • search_customer выполняется на MCP-сервере, ответ передается обратно в модель

  • в истории оказывается список клиентов, часто с лишними полями: телефоны, теги, служебные id, старые статусы

  • модель читает список и для КАЖДОГО клиента из списка

    • формирует запрос к tool get_customer, list_deals, list_next_actions, list_meetings и кладет его в контекст разговора

    • get_customer, list_deals, list_next_actions, list_meetings выполняются на MCP-сервере, ответ передается обратно в модель

  • из всех этих данных, занимающих много места в контексте, чат пробует сделать красиво оформленный ответ

Каждый промежуточный JSON остаётся в контексте. Следующий ход модели заново тащит уже прочитанные карточки, сделки, действия и встречи. На десяти клиентах это один поиск плюс четыре вызова на каждого: больше сорока tool results в истории. Данных для модели очень много, модель может вообще часть из них упустить + все это будет лежать в контексте до конца разговора.

Вот тут PTC как раз показывает, на что он способен. Большая часть логики достаточно детерминированная, и скрипт может сделать ее сам. Чтобы скрипт мог вызвать те же tools сам, разработчик явно перечисляет их в PTC при создании агента CodeInterpreterMiddleware:

CodeInterpreterMiddleware(
    ptc=[
        "get_current_datetime",
        "search_customer",
        "get_customer",
        "list_deals",
        "list_next_actions",
        "list_meetings",
    ],
)

С PTC модель САМА пишет одну программу под тот же вопрос про Самару:

const now = new Date(await tools.getCurrentDatetime({}));
const mskOffsetMs = 3 * 3600000; // МСК, как в примере с полнолунием
const msk = new Date(now.getTime() + mskOffsetMs);
const daysUntilMonday = (1 + 7 - msk.getUTCDay()) % 7 || 7;
const weekStart = new Date(
  Date.UTC(msk.getUTCFullYear(), msk.getUTCMonth(), msk.getUTCDate() + daysUntilMonday) - mskOffsetMs,
);
const weekEnd = new Date(weekStart.getTime() + 7 * 86400000);

const inNextWeek = (value) => {
  if (!value) return false;
  const t = new Date(value);
  return t >= weekStart && t < weekEnd;
};

const found = await tools.searchCustomer({ city: "Самара" });
const customers = Array.isArray(found) ? found : (found && found.items) || [];

const rows = await Promise.all(
  customers.map(async (hit) => {
    const [card, deals, actions, meetings] = await Promise.all([
      tools.getCustomer({ id: hit.id }),
      tools.listDeals({ customerId: hit.id }),
      tools.listNextActions({ customerId: hit.id }),
      tools.listMeetings({ customerId: hit.id }),
    ]);

    const calls = actions.filter((action) => action.type === "call" && inNextWeek(action.due));
    const weekMeetings = meetings.filter((meeting) => inNextWeek(meeting.start));

    return {
      name: card.name,
      meetings: weekMeetings.slice(0, 5),
      calls: calls.slice(0, 5),
      openDeals: deals.filter((deal) => deal.status !== "closed").length,
    };
  }),
);

return rows.filter((row) => row.meetings.length > 0 || row.calls.length > 0);

Те же четыре tools на каждого клиента, что и в ReAct, плюс рамка «следующая неделя» и фильтр созвонов и встреч. Всё это происходит внутри интерпретатора. Модель увидит только объект из return: с кем встреча, с кем звонок.

Здесь есть экономия токенов: не нужно после каждого вызова отправлять модели новый запрос с полным предыдущим контекстом. В истории не остаются закрытые сделки, служебные поля CRM и полные карточки встреч. Цикл по customers пройдёт по всем элементам списка, что часто проблема для длинных цепочек, управляемых самой LLM по длинной истории. Интерпретируемый код не может забыть, сколько заказчиков он получил на этапе поиска, он пройдет по ним всем.

Но чтобы написать такой скрипт, LLM должна быть достаточно умная. Скрипт должен скомпилироваться и правильно работать со схемами. Ошибка в нём ломает весь составной шаг, а не один вызов, при ошибке модель сама правит код и пробует еще раз. PTC плохо подходит для одиночного чтения файла или одного запроса к API. Он окупается там, где между несколькими вызовами есть повторяемая логика, которую простой скрипт решает лучше модели.

Последний важный момент для полной прозрачности. Как только мы добавили параметр ptc в CodeInterpreterMiddleware, этот класс также обновит наш системный промпт, добавив туда общее описание ptc и разрешенных ему tools.

### API Reference — `tools` namespace

The agent tools listed below are exposed on the global object at `globalThis.tools` (also reachable as `tools`). Each takes a single object argument and returns a Promise that resolves to the tool's native value: strings as strings, numbers as numbers, lists as arrays, dicts as objects, and `None` as `null`. You do NOT need to `JSON.parse` results — they are already typed.

Invocation pattern: `await tools.<name>({ ... })`.

- Use `await` to get tool results; combine with `Promise.all` for independent calls so they run concurrently.
- If the task needs multiple tool calls, prefer one `eval` invocation that performs all of them rather than splitting the work across multiple `eval` calls — each round-trip costs a model turn.
- Pipeline dependent calls within a single program. If a result from one tool is needed as input to a later tool, chain them in one program instead of returning the intermediate value to the model.
- If a tool returns an ID or other value that can be passed directly into the next tool, trust it and chain the calls instead of stopping to double-check it.
- To inspect an intermediate value, `console.log` it inside the same program; otherwise, fetch as much information as possible in one call.
- Only split work across multiple `eval` invocations when you genuinely cannot determine what to do next without additional model reasoning or user input.

Example shape — substitute real tool names:

```typescript
const users = await tools.findUsers({ name: "Ada" });
const userId = users[0].id;
const [city, normalized] = await Promise.all([
  tools.cityForUser({ user_id: userId }),
  tools.normalize({ name: "Ada" }),
]);
console.log({ city, normalized });
```

```typescript
/** Возвращает текущую дату и время с часовым поясом */
tools.getCurrentDatetime(input: Record<string, unknown>): Promise<unknown>
```

Критически важный момент про PTC - LangChain на вызовы tools из скрипта проигнорирует требования по подтверждению пользователем (Human-in-the-Loop). Т.е. вы можете указать interruptOn с перечнем tools, но такие подтверждения будут запрашиваться только на вызовы tools по запросу модели напрямую, а вот скрипт их выполнит без каких-либо запросов, так что в PTC можно разрешать только те инструменты (tool), что не требуют подтверждения. При этом вы можете запросить подтверждение на весь tool eval, но тогда вам нужно проверять и подтверждать весь скрипт, что он хочет выполнить.

Т.е. в Deep Agents практическая стратегия на текущий момент:

  1. В PTC-allowlist отдавать читающие инструменты: поиск, получение карточки, список файлов, статистику.

  2. Пишущие инструменты оставлять обычными вызовами, где interruptOn видит каждое действие.

  3. Ограничивать число PTC-вызовов через maxPtcCalls.

  4. Если сценарий всё же требует действий, подтверждать текст программы до запуска.

Другие harness показывают, что мост между скриптом и tools можно построить иначе. В DeepSeek Harness Code mode каждый внутренний await tools.name() снова проходит полный pipeline: правила, guard, approval, исполнение, логирование. Человек может подтвердить и программу, и конкретный вложенный вызов. У Anthropic Programmatic Tool Calling код в контейнере останавливается на внутреннем tool call, а клиент получает обычный tool_use и сам решает, продолжать ли. Cloudflare Code Mode поддерживает requiresApproval на методах connector и pause/approve/resume в длительном запуске. Все три подхода сохраняют важную идею: промежуточные данные не попадают в контекст модели, но вложенные действия не исчезают из политики и аудита. Очень надеюсь, что LangChain пересмотрит этот вопрос со временем.

Но и это еще не все возможности JS-интерпретатора в LangChain. Скрипты также могут работать с субагентами, впрочем, это уже совершенно другая история … и ее можно осветить в следующей статье…