Агент-инженер по репозиторию · Модуль 1 · Урок 1.3

Первый инструмент: read_file (схема, tool_use, tool_result)

Зачем (какую проблему чиним)

Чтобы ответить на вопрос о файле, агент должен этот файл прочитать. Модель сама файлы не читает — она может лишь попросить инструмент. Дадим ей первый инструмент read_file: модель присылает блок tool_use с именем и аргументами, мы исполняем и возвращаем tool_result.

Решение и альтернативы

Решение: объявляем инструмент с JSON-схемой входа (имя файла), регистрируем обработчик, и в цикле при stop_reason == tool_use исполняем каждый запрошенный инструмент, возвращая результат тем же tool_use_id. Уже здесь закладываем безопасность чтения: путь обязан оставаться внутри корня репозитория (защита от ../../etc/passwd).

Альтернативы: отдавать модели весь файл в системном промпте заранее — не масштабируется (версия v2 про это целиком) и засоряет окно. Один универсальный инструмент «выполни что угодно» — дыра в безопасности; мы идём узкими, явными инструментами с валидацией.

DIFF

Добавляем описание инструмента, обработчик с проверкой пути и ветку исполнения инструментов в цикле.

⚠ Безопасность

Любой путь от модели — недоверенный ввод. read_file обязан: привести путь к абсолютному внутри корня репозитория и отвергнуть всё, что выходит за корень (path traversal). Это первый пример сквозного правила: инструмент агента валидирует вход так, будто он враждебный, — потому что через prompt injection он может таким и оказаться.

Проверка

Спросите агента «что делает функция main в main.go?». В логе видно: модель прислала tool_use read_file → мы вернули содержимое → модель ответила по сути. Запрос файла вне корня (../secret) — отклонён с ошибкой.

Глубже

Дизайн инструментов и схем, обработка ошибок, песочницы — курс «Продакшн-разработка», Модуль 4 (инструменты и MCP, гл. 7–8) и курс архитектора (CCA), модуль про проектирование инструментов и MCP.

tools.go: описание инструмента read_file и обработчик с защитой пути (новый файл)
+package main
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/anthropics/anthropic-sdk-go"
+)
+
+// readFileTool — описание инструмента для модели: имя, назначение, схема входа.
+func readFileTool() anthropic.ToolParam {
+	return anthropic.ToolParam{
+		Name:        "read_file",
+		Description: anthropic.String("Прочитать текстовый файл репозитория по относительному пути."),
+		InputSchema: anthropic.ToolInputSchemaParam{
+			Properties: map[string]any{
+				"path": map[string]any{
+					"type":        "string",
+					"description": "Путь относительно корня репозитория, напр. main.go",
+				},
+			},
+			Required: []string{"path"},
+		},
+	}
+}
+
+// safeJoin не выпускает путь за пределы корня репозитория (anti path-traversal).
+func safeJoin(root, rel string) (string, error) {
+	abs := filepath.Join(root, filepath.Clean("/"+rel))
+	if !strings.HasPrefix(abs, filepath.Clean(root)+string(os.PathSeparator)) && abs != filepath.Clean(root) {
+		return "", fmt.Errorf("путь вне репозитория: %s", rel)
+	}
+	return abs, nil
+}
+
+// execReadFile — обработчик инструмента: валидирует путь и читает файл.
+func execReadFile(root, rel string) (string, error) {
+	abs, err := safeJoin(root, rel)
+	if err != nil {
+		return "", err
+	}
+	b, err := os.ReadFile(abs)
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
main.go: регистрируем инструмент и исполняем tool_use в цикле
 func (a *Agent) callModel(ctx context.Context, msgs []anthropic.MessageParam) (*anthropic.Message, error) {
 	return a.client.Messages.New(ctx, anthropic.MessageNewParams{
 		Model:     anthropic.Model(a.cfg.Model),
 		MaxTokens: int64(a.cfg.MaxTokens),
 		Messages:  msgs,
+		Tools: []anthropic.ToolUnionParam{
+			{OfTool: &[]anthropic.ToolParam{readFileTool()}[0]},
+		},
 	})
 }

 		msgs = append(msgs, resp.ToParam())
 		if resp.StopReason != anthropic.StopReasonToolUse {
 			return textOf(resp), nil
 		}
-		// Исполнение инструментов добавим в уроке 1.3.
+		// Исполняем каждый запрошенный инструмент и возвращаем результаты.
+		var results []anthropic.ContentBlockParamUnion
+		for _, block := range resp.Content {
+			tu, ok := block.AsAny().(anthropic.ToolUseBlock)
+			if !ok {
+				continue
+			}
+			out, err := a.dispatch(tu)
+			results = append(results, anthropic.NewToolResultBlock(tu.ID, out, err != nil))
+		}
+		msgs = append(msgs, anthropic.NewUserMessage(results...))
main.go: диспетчер инструментов (пока один read_file)
+// dispatch исполняет один tool_use. Возвращает текст результата; флаг ошибки
+// проставляется вызывающим через err != nil.
+func (a *Agent) dispatch(tu anthropic.ToolUseBlock) (string, error) {
+	switch tu.Name {
+	case "read_file":
+		var in struct{ Path string }
+		if err := json.Unmarshal(tu.Input, &in); err != nil {
+			return "неверные аргументы: " + err.Error(), err
+		}
+		out, err := execReadFile(a.cfg.Root, in.Path)
+		if err != nil {
+			return err.Error(), err
+		}
+		return out, nil
+	default:
+		return "неизвестный инструмент: " + tu.Name, fmt.Errorf("unknown tool %q", tu.Name)
+	}
+}

Anti-patterns

Грабли первого инструмента
ГрабляПочему плохоКак правильно
Доверять пути от модели как есть (os.ReadFile(rel))Path traversal: модель (или инъекция) читает ../../etc/passwd или секреты вне репозиторияВалидировать путь через safeJoin: только внутри корня репозитория
Один универсальный инструмент «выполни команду/код»Произвольное исполнение — дыра в безопасности; невозможно ограничитьУзкие явные инструменты с JSON-схемой и валидацией каждого аргумента
Не возвращать tool_result на каждый tool_useПротокол ломается: модель ждёт результат по tool_use_id, иначе зависает или ошибаетсяНа каждый запрошенный инструмент вернуть результат с тем же tool_use_id
Глотать ошибку инструмента молча (вернуть пустую строку без флага ошибки)Модель не понимает, что инструмент упал, и строит ответ на пустотеВозвращать текст ошибки и помечать is_error=true, чтобы модель отреагировала

Практическое задание (RA-v0)

  • Добавить Root в Config (корень анализируемого репозитория) и read_file с safeJoin.
  • В цикле при tool_use исполнять инструменты и возвращать tool_result по tool_use_id (флаг is_error при ошибке).
  • Проверить отказ на ../-пути. Закоммитить: git commit -m "v0: add read_file tool with path safety".

Проверка знаний

Обработчик read_file реализован как os.ReadFile(relPathFromModel) без проверок.

Какая уязвимость здесь и как её закрыть?

  • A Утечка памяти; добавить defer Close
  • B Path traversal: модель/инъекция может выйти за корень (../../); валидировать путь, оставляя его внутри корня репозитория
  • C Ничего, os.ReadFile безопасен
  • D Нужно кэшировать файл

Модель в одном ответе прислала два блока tool_use. Что обязан сделать цикл перед следующим вызовом модели?

  • A Исполнить только первый инструмент
  • B Исполнить оба и вернуть два tool_result, каждый со своим tool_use_id, одним user-сообщением
  • C Сделать два отдельных вызова модели
  • D Проигнорировать, раз их несколько