Skip to content

Paths into a value, and a handbook the tool can reach - #13

Merged
inhuman merged 3 commits into
mainfrom
nested-paths
Aug 14, 2026
Merged

Paths into a value, and a handbook the tool can reach#13
inhuman merged 3 commits into
mainfrom
nested-paths

Conversation

@inhuman

@inhuman inhuman commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Two embedder tasks, both measured on the same three days of a model writing
skills. Format 2.4.0.

1. A reference may be a PATH

instruction: "{{pod.metadata.name}} restarted {{pod.status.containerStatuses[0].restartCount}} times"
cond: "pod.status.containerStatuses[0].restartCount > 0"

Substitution used to stop at one field, and the comment beside it said the
observed cases were flat. They were, and then they were not: a tool's answer is
as deep as the tool made it. Of sixteen live generations of a step description,
six were refused by the format and five of the six were this one case — and
the same measurement with the list of allowed fields in the prompt (4.3 KB of
it) held the same share of refusals. The format was short of a form, not the
author short of the docs.

An index is [0], not .0: that is what authors write, and it keeps a path
unambiguous — .0 would be the field named "0", a legal JSON key, and an index
at the same time. Still absent on purpose: [*], filters, arithmetic. A
condition written with [*] gets a refusal that names the loop instead.

A path that does not resolve is an error, and the message says where the
walk broke and what the object did have. The line is drawn at what the old
grammar could express: a bare var and a single var.field keep their silence,
because skills written under that promise live in other people's storage.

The shape of a reference now has ONE definition (RefPattern), built into
substitution, all four condition forms and the linter. The spellings had already
drifted apart.

Two behaviour changes came out of review:

  • a broken path in an exit reason no longer cancels the exit. The reason is a
    caption; exit is how a skill hands the turn back, and a consumer tells that
    apart from a failure on purpose;
  • set, switch and if now leave a trace when they fail and obey the step's
    on_error — they could not fail at all before, so neither half was wired up,
    while on_error was already being parsed for them.

2. The handbook travels with the module

handbook/, embedded and reachable the way the schema is:

skillengine.HandbookIndex()          // ~1.4 KB, fits in a prompt whole
skillengine.Handbook("flow-shape")   // a section, on demand

It existed and could not be reached: it lived in the ignored spec tree, so it
was in no repository, no module and no vendor/. Now it also cannot drift from
the engine — which it already had, within a day: its rule "a condition reads one
level" was made false by the first half of this PR.

Why forms rather than prose: the hint "if you need a subset, make it a separate
step" worked 0 times out of 16, while an example gets copied. Every section now
opens with a piece to copy. Why reachable rather than merely written: the old
pointer said "call the schema tool", which no skill had in its radius and no
skill-writing step could call anyway (tools: []).

Each linter rule that covers a handbook class carries its section id
(Rule.Handbook, Finding.Handbook) — a field, not a sentence glued to the
message, because whoever assembles the refusal decides what to do with it.

Two things the handbook is not, both guarded by tests: not a second source of
truth
(a section naming a field the schema does not have is refused), and
not one installation's vocabulary — the tool names, telemetry fields,
clusters and skill names it arrived with are gone. Five published versions of
this library were retracted for exactly that class. The private names the guard
matches against are NOT in the test: they come from the list the git hooks
share, which is gitignored, and the test skips where it is absent.

The handbook is in Russian, like the failure reports it was written from; the
schema and both READMEs stay bilingual.

Подстановка читала один уровень, рядом стояло «наблюдаемые случаи плоские».
Наблюдение было верным и перестало быть: ответ инструмента настолько
глубокий, насколько его сделал инструмент. На 16 живых генерациях описания
шага формат отверг шесть, и ПЯТЬ из шести — один случай: число на третьем
уровне ответа kubectl. Список допустимых полей в промпте (4.3 КБ) долю
отказов не сдвинул — не хватало формы, а не документации.

Теперь `var.a.b.c` и `var.items[0].name` — и в подстановке, и слева от
условия. Индекс пишется `[0]`, а не `.0`: так его пишут авторы, и так путь
остаётся однозначным (`.0` — это ещё и поле с именем «0», законный ключ
JSON).

Форма ссылки теперь описана ОДИН раз (RefPattern) и вшита во все регулярки,
которые её читают: подстановка, четыре формы условий, линтер. Раньше каждая
писала её сама, и написания уже разошлись — `{{a.b}}` умело один уровень,
условие принимало сколько угодно точек и точку в конце.

Промах пути — ошибка шага, а не пустая строка: `a.b.c` при отсутствующем `b`
неотличимо от «значение пустое», а по нему ветвятся. В отказе сказано, где
путь оборвался и какие поля у объекта есть на самом деле. Граница проведена
по тому, что старая грамматика умела выразить: голое `var` и одиночное
`var.field` сохраняют молчание — на нём написаны чужие скиллы, лежащие в
пользовательском хранилище, и апгрейд движка не имеет права их ронять.
Молчащую половину сторожит W14.

`[*]` отвергается отдельным сообщением, называющим цикл: автор не опечатался
в синтаксисе, он попросил язык запросов, а нужен ему for_each.

Побочно: lookup+objectOf заменены одним resolve, guard-тест резолвера
переведён на него; expand/payload/callArgs возвращают ошибку — путь умеет
не разрешиться.
1. Комментарий про valueOf утверждал, что подстановка в поле «никогда не
работала» на результатах call. Неправда: фолбэк в память стоит в objectOf с
первого коммита (afe751d), я перенёс фразу дословно, не проверив. Переписано
на «почему обработка здесь нужна», без истории. Та же фраза была в
комментарии теста — убрана и там; в репозитории копий не осталось.

2. exit больше не отменяется собственной подписью. Подпись — это caption, а
exit — способ скилла сказать «не мой случай, верните ход обычным путём»;
потребитель отличает выход от отказа специально (скилл, запущенный поимённо,
на отказе ход останавливает, на выходе нет). Непроходящий путь в подписи
превращал одно в другое молча. Теперь подстановка там best-effort: что не
разрешилось, остаётся как написано, скобками наружу, где это видно читателю.
Закрыто двумя тестами — раньше поведение не было закреплено вовсе.

3. set, switch и if при отказе оставляют трассу и слушают on_error. До
появления путей эти три рода шага не умели отказывать вовсе, поэтому обе
половины у них не были подключены, — при том что on_error лежит в inline Run,
откуда его читают все остальные роды. Молча игнорировать разобранное поле
— ровно тот класс «объявлено и не действует», который формат и вылавливает.
Заодно Flow.Validate проверяет значение on_error у любого рода шага, а не
только рядом с инструкцией: неизвестное значение теперь означало бы тихий
abort.
Справочник был написан 3 августа и лежал в specs/, то есть в .gitignore:
его не было ни в репозитории, ни в модуле, ни в vendor/ у встраивающего. Он
существовал на одной машине, при том что его собственный README адресует его
в том числе инструменту, который пишет скиллы.

Теперь handbook/ с go:embed и доступом как у схемы:

    skillengine.HandbookIndex()      // ~1.4 КБ, кладётся в промпт целиком
    skillengine.Handbook("flow-shape")

Разделами, а не целиком: 70 КБ в шаг не влезают и не должны. Проверено не
изнутри репы, а из отдельного модуля через replace — то же, что vendor/.

Почему указатель обязан вести туда, куда адресат может пойти: за три дня
сборки скиллов моделью одиннадцать коммитов подряд — один класс, форма,
которой в формате нет. Перечень полей в промпте не помогает (1 из 8 против
0 из 8, в пределах шума), проза не помогает («сделай отбор отдельным шагом»
— 0 из 16), а прежний указатель «позови schema-тул» на пути программы мёртв
дважды: тула нет в радиусе, а у шагов сборки tools: []. Поэтому раздел
теперь начинается с готовой ФОРМЫ, и её можно достать вызовом.

Правило линтера, покрытое разделом, несёт его id: Rule.Handbook и
Finding.Handbook. Полем, а не приклеенной фразой — отказ собирает
встраивающий: человеку «см. также», инструменту id, по которому он сходит.

Справочник не становится вторым источником правды: имена полей живут в
схеме, и тест отвергает раздел, назвавший поле, которого в схеме нет
(отличая поля формата от полей пользовательской response_schema по типу
справа).

Из содержания вычищен словарь чужой установки — протокол вызова, поля
телеметрии, кластеры, имена скиллов и стадии конвейера. За это уже
ретрачено пять версий, поэтому заведены два сторожа: публичный файл не имеет
права называть чужую установку (имена берутся из .githooks/private-names,
в самом тесте их нет — он публичный), а справочник не имеет права звучать
написанным внутри неё.

P2 переписан: «условие читает один уровень» стало ложью через сутки после
написания — пути и числовые сравнения уже в движке.
@inhuman
inhuman merged commit 85fc5a1 into main Aug 14, 2026
1 check passed
@inhuman
inhuman deleted the nested-paths branch August 14, 2026 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant