Урок 5. Сборка CLI-агента

Собираем всё вместе: цикл из урока 2, ограждения из урока 3, инструменты из урока 4. Получается CLI-агент — программа, которая в цикле думает, зовёт инструменты и отвечает. Ровно так дирижирует ZCode: задача → рассуждение → инструмент → наблюдение → снова, пока не готов ответ.

   ┌───────────────────────────────┐
   │    задача пользователя        │
   └───────────────┬───────────────┘
                   │
                   ▼
   ┌───────────────────────────────┐
  ▶│ КОНТЕКСТ (system + история)   │
  │└───────────────┬───────────────┘
  │                │
  │                ▼
  │            ┌───────┐
  │            │  LLM  │  финальный текст
  │            └───┬───┬─────────────────▼
вызов JSON         │                ┌─────────┐
  │                ▼                │  ОТВЕТ  │
  │┌───────────────────────────────┐│ (текст) │
  ││ ОГРАЖДЕНИЯ (белый список)     │└─────────┘
  ││ перехват ошибок, проверка     │
  │└───────────────┬───────────────┘
  │                │
  │                ▼
  │┌───────────────────────────────┐
  ││ ИНСТРУМЕНТ: calc / time       │
  │└───────────────┬───────────────┘
  │                │
  ┐────────────────└наблюдение

Запуск

Три переменные окружения — подходит любой OpenAI-совместимый API (OpenAI, Groq, POLZA, локальный Ollama):

Переменная Что это Пример
LLM_URL эндпоинт chat completions https://api.openai.com/v1/chat/completions
LLM_KEY ключ API sk-...
LLM_MODEL имя модели gpt-4o-mini или sao10k/l3-lunaris-8b
python mini_agent.py "Сколько будет 123*45, и который час?"
node mini_agent.js "Сколько будет 123*45, и который час?"

Код целиком — Python (~60 строк)

# mini_agent.py — минималистичный CLI-агент
import datetime, json, os, re, urllib.request

LLM_URL = os.environ.get("LLM_URL", "https://api.openai.com/v1/chat/completions")
LLM_KEY = os.environ.get("LLM_KEY", "no-key")
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-4o-mini")
MAX_STEPS = 6

SYSTEM = ("Ты — агент-помощник в терминале. Отвечай по-русски, кратко. "
          "Если нужен инструмент, ответь ровно одним JSON-объектом: "
          '{"tool": "calc" или "time", "args": {...}}. Иначе — обычный текст.')

def calc(expr):
    return eval(expr, {"__builtins__": {}}, {})   # без импортов — ограждение

def time():
    return datetime.datetime.now().strftime("%H:%M %d.%m.%Y")

TOOLS = {"calc": calc, "time": time}

def chat(messages):
    req = urllib.request.Request(
        LLM_URL,
        data=json.dumps({"model": LLM_MODEL, "messages": messages,
                         "temperature": 0.2}).encode(),
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {LLM_KEY}"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.load(r)["choices"][0]["message"]["content"]

def parse_tool(text):
    try:
        obj = json.loads(text)
    except json.JSONDecodeError:
        m = re.search(r"\{.*\}", text, re.DOTALL)
        try:
            obj = json.loads(m.group(0)) if m else None
        except json.JSONDecodeError:
            obj = None
    return obj if isinstance(obj, dict) and "tool" in obj else None

def run_tool(call):
    fn = TOOLS.get(call.get("tool"))
    if not fn:
        return "Инструмент не найден"
    try:
        result = fn(**(call.get("args") or {}))
    except Exception as e:
        return f"Инструмент упал: {e}"
    if result is None:
        return "Инструмент вернул пустоту"
    return result

def run(task):
    messages = [{"role": "system", "content": SYSTEM},
                {"role": "user", "content": task}]
    for _ in range(MAX_STEPS):                 # Harness: лимит шагов
        reply = chat(messages)
        call = parse_tool(reply)
        if not call:
            return reply                       # финальный ответ
        messages.append({"role": "assistant", "content": reply})
        messages.append({"role": "user",
                         "content": f"Наблюдение: {run_tool(call)}"})
    return f"Остановлен лимитом шагов ({MAX_STEPS})"

if __name__ == "__main__":
    task = input("агент> ").strip() or "Сколько будет 123*45, и который час?"
    print(run(task))

Код целиком — JavaScript / Node (~55 строк)

// mini_agent.js — минималистичный CLI-агент (Node 18+, fetch встроен)
const LLM_URL = process.env.LLM_URL || "https://api.openai.com/v1/chat/completions";
const LLM_KEY = process.env.LLM_KEY || "no-key";
const LLM_MODEL = process.env.LLM_MODEL || "gpt-4o-mini";
const MAX_STEPS = 6;

const SYSTEM = "Ты — агент-помощник в терминале. Отвечай по-русски, кратко. " +
  "Если нужен инструмент, ответь ровно одним JSON-объектом: " +
  '{"tool": "calc" или "time", "args": {...}}. Иначе — обычный текст.';

const TOOLS = {
  calc: (args) => Function('"use strict"; return (' + args.expr + ')')(),
  time: () => new Date().toLocaleString("ru-RU"),
};

async function chat(messages) {
  const r = await fetch(LLM_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json",
               Authorization: `Bearer ${LLM_KEY}` },
    body: JSON.stringify({ model: LLM_MODEL, messages, temperature: 0.2 }),
  });
  const data = await r.json();
  return data.choices[0].message.content;
}

function parseTool(text) {
  try {
    const obj = JSON.parse(text);
    return obj && obj.tool ? obj : null;
  } catch {
    const m = String(text).match(/\{[\s\S]*\}/);
    if (!m) return null;
    try {
      const obj = JSON.parse(m[0]);
      return obj && obj.tool ? obj : null;
    } catch { return null; }
  }
}

function runTool(call) {
  const fn = TOOLS[call.tool];
  if (!fn) return "Инструмент не найден";
  try {
    const result = fn(call.args || {});
    if (result === undefined || result === null)
      return "Инструмент вернул пустоту";
    return String(result);
  } catch (e) {
    return "Инструмент упал: " + e;
  }
}

async function run(task) {
  const messages = [{ role: "system", content: SYSTEM },
                    { role: "user", content: task }];
  for (let step = 0; step < MAX_STEPS; step++) {
    const reply = await chat(messages);
    const call = parseTool(reply);
    if (!call) return reply;
    messages.push({ role: "assistant", content: reply });
    messages.push({ role: "user", content: "Наблюдение: " + runTool(call) });
  }
  return "Остановлен лимитом шагов (" + MAX_STEPS + ")";
}

const task = process.argv[2] || "Сколько будет 123*45, и который час?";
run(task).then(console.log);

Пример сессии

$ python mini_agent.py "Сколько будет 123*45, и который час?"
[шаг 1] LLM: {"tool": "calc", "args": {"expr": "123*45"}}   ← вызов
        наблюдение: 5535                                      ← в контекст
[шаг 2] LLM: {"tool": "time", "args": {}}                    ← вызов
        наблюдение: 14:32 24.08.2026                          ← в контекст
[шаг 3] LLM: 123 * 45 = 5535, сейчас 14:32 (24.08.2026).     ← ответ

Практика

  1. Запусти агента на своём ключе (переменные LLM_URL / LLM_KEY / LLM_MODEL).
  2. Добавь инструмент из урока 4 (dice или flip) — правка в двух местах: TOOLS и системный промпт.
  3. Задай задачу, в которой агент ошибётся и поправится через наблюдение — посмотри на траекторию в контексте.

Что дальше

Куда расти: память через RAG (гайд «RAG с нуля»), стандартные инструменты через MCP, оценка качества — темы следующих гайдов раздела. Теория под каждый шаг — в заметках: Агентный цикл в коде, Harness-инженерия, Код как инструмент мышления.

Связано: Урок 4. Инструменты, Обучение