Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,113 @@ wrap skills in something of your own — front matter, a markdown body, several
documents in one file — unwrap before calling and wrap the result back;
anything else is refused rather than guessed at.

## 2.4.0

A reference may now be a PATH, and the skill author's handbook travels with the
module.

- **Added**: `handbook/` — the failure classes of the format and the forms that
avoid them, embedded and reachable from code:

```go
for _, s := range skillengine.HandbookIndex() { … } // ~1.4 KB, fits in a prompt
text := skillengine.Handbook("flow-shape") // a section, on demand
```

It existed before this release 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 cannot drift from the engine either — updating the module updates the
handbook.

Why it has to be reachable rather than merely written. Over three days of a
model writing skills, eleven commits in a row were one class — a form the
format does not have, a different one each time. Three measurements from those
days: the LIST OF FIELDS in the prompt does not help (invented keys 1 of 8
against 0 of 8, inside the noise); PROSE does not (the hint "make it a separate
step" worked 0 times out of 16, while a ready form gets copied); and a POINTER
works only where the addressee can go — the refusal used to end with "call the
schema tool", which no skill had in its radius. So: whole forms, fetched by a
tool. Every section now opens with a piece to copy.

Each rule of the linter that covers a handbook class carries its section id —
`Rule.Handbook`, and `Finding.Handbook` on every finding it makes.

Two things the handbook deliberately is not. It is **not a second source of
truth about the format**: names and types of fields live in the schema, and a
test refuses a section that names a field the schema does not have. And it
carries **no installation's vocabulary** — the tool names, telemetry fields,
clusters and skill names of the deployment it was written in are gone, and a
test keeps them out. It is in Russian, like the failure reports it was written
from; the schema and the READMEs stay bilingual.

A skill that uses no path behaves exactly as it did; one that uses a path must
declare `skill_engine_version: 2.4.0`. The handbook needs no declaration — it is
documentation, not format.

- **Added**: a path of any depth, and an index into a list — in a substitution
and on the left of a condition alike:

```yaml
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. Measured on sixteen live generations of a step
description, six were refused by the format, and FIVE of the six were the same
case — a number three levels inside a `kubectl get` answer. Not a model
failing to learn the format either: the same measurement with the list of
allowed fields in the prompt (4.3 KB of it) held the same share of refusals.

An index is written `[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 deliberately absent: `[*]`, filters, arithmetic, functions. That is
where a format turns into a query language. A condition written with `[*]` now
gets a refusal of its own, naming the loop that does what was asked.

- **Added, and a behaviour change**: **a path that does not resolve is an
error.** The step fails, and the message says where the walk broke and what
the object did have (`` `pod.status` has no field `restarts` (it has:
containerStatuses, phase) ``).

An unknown name still expands to an empty string in silence — a deliberate
decision, because a marker reaching the model reads as part of the
instruction. For a path that silence is worse: `a.b.c` with `b` missing is
indistinguishable from "the value is empty", and branches are taken on it.
Same call the numeric operands make.

**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 and must not start failing on an
upgrade. Anything deeper, and any index, is new syntax that owes nothing to
the old contract. The silent half is watched statically by the linter's W14 —
the only place it can be watched at all.

- **Changed**: `set`, `switch` and `if` now leave a TRACE when they fail and
obey the step's `on_error`. Until a reference became a path these three could
not fail at all, so neither half was ever wired up for them — while
`on_error` is a step-level key that lands in the same place the other kinds
read it from. An unknown value in it is now refused by `Flow.Validate` for
every kind, not only beside an instruction.

- **Changed**: a path that does not resolve inside an `exit` REASON no longer
cancels the exit. The reason is a caption; `exit` is how a skill hands the
turn back ("not my case"), and a consumer tells that apart from a failure on
purpose — a skill run by name stops the turn when it fails and does not when
it leaves. Substitution there is best-effort: whatever did not resolve stays
as the author wrote it, braces and all, where the reader of the reason can
see it.

- **Changed (Go API)**: the substitution and resolution helpers now return an
error, since a path can fail: `expand`, `expandForArgs`, `expandArgs`,
`callArgs`, `payload` and `expandWhole` are internal, but `RefPattern` is new
and exported — the shape of a reference, so that a linter, an editor or a
visualiser finds one the way the engine does instead of spelling out a
narrower grammar and silently skipping what it cannot parse.

## 2.3.0

Four new condition forms. A skill that does not use them behaves exactly as it
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@ reading when you are writing a skill, not before.
| [`examples/skills/`](examples/skills/) | the format itself — thirteen skills, each a commented example |
| [`examples/`](examples/) | two applications that embed the engine, both runnable offline |
| [skill.schema.yaml](skill.schema.yaml) | the source of truth for the format (`SchemaRU` is the same in Russian) |
| [handbook/](handbook/) | the failure classes and the forms that avoid them — in Russian, and reachable from code (`HandbookIndex`, `Handbook`) |
| [lint/README.md](lint/README.md) | the rule table, and what deliberately stays with the embedder |
| [CHANGELOG.md](CHANGELOG.md) | what changed in each format version, and what a migration does |

Expand Down Expand Up @@ -565,6 +566,19 @@ it is the stored value that flows on. It works on `call` steps too, except
- `save_as` puts a step's result into a variable; **a step without `save_as`
writes into `answer`** — that is where the application takes the turn's
answer from. An empty `answer` = the program produced no answer.
- **A value inside a structured result is reached by a path**, in a
substitution and in a condition alike: `{{pod.metadata.name}}`,
`{{pod.status.containerStatuses[0].restartCount}}`, and the same written
without braces on the left of a condition. An index is `[0]`; `[*]` and
filters are refused, because a path resolves to ONE value and picking many is
what `for_each` is for.

**A path that does not resolve is an error, not an empty string.** Silence
there is worse than useless: `a.b.c` with `b` missing is indistinguishable
from "the value is empty", and branches are taken on it. The refusal says
where the walk broke and what the object did have. A bare name and a single
`var.field` keep their old silence — that promise is what skills already
written were built on, and the linter's W14 is what watches it.
- `<name>.mem` — the working-memory handle of a result, ALWAYS, not only for
large ones: `args: {stdin: {from: "{{tickets.mem}}"}}` sends the data past
the model's context. It is read from the value's LAST line, where the host
Expand Down
13 changes: 13 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ react-цикл: каждое решение (какой инструмент, х
| [`examples/skills/`](examples/skills/) | сам формат — тринадцать скиллов, каждый с комментариями |
| [`examples/`](examples/) | два приложения, встраивающих движок, оба гоняются без сети |
| [skill.schema.yaml](skill.schema.yaml) | источник истины про формат (`SchemaRU` — то же по-русски) |
| [handbook/](handbook/) | классы отказов и формы, которые их обходят; доступен из кода (`HandbookIndex`, `Handbook`) |
| [lint/README.md](lint/README.md) | таблица правил и то, что намеренно осталось у встраивающего |
| [CHANGELOG.md](CHANGELOG.md) | что менялось в каждой версии формата и что делает миграция |

Expand Down Expand Up @@ -544,6 +545,18 @@ steps:
- `save_as` кладёт результат шага в переменную; **шаг без `save_as` пишет в
`answer`** — оттуда приложение берёт ответ хода. Пустой `answer` = программа
ответа не дала.
- **До значения внутри структурного результата ведёт путь** — одинаково в
подстановке и в условии: `{{pod.metadata.name}}`,
`{{pod.status.containerStatuses[0].restartCount}}` и то же самое без скобок
слева от условия. Индекс пишется `[0]`; `[*]` и фильтры отвергаются: путь
разрешается в ОДНО значение, а выбирать многое — это `for_each`.

**Путь, не разрешившийся до значения, — ошибка, а не пустая строка.** Молчание
здесь хуже, чем бесполезно: `a.b.c` при отсутствующем `b` неотличимо от
«значение пустое», а по нему ветвятся. В отказе сказано, где путь оборвался и
что у объекта было на самом деле. Голое имя и одиночное `var.field` сохраняют прежнее
молчание: на этом обещании написаны уже существующие скиллы, а сторожит его
правило линтера W14.
- `<имя>.mem` — хендл рабочей памяти результата, ВСЕГДА, не только у крупных:
`args: {stdin: {from: "{{tickets.mem}}"}}` шлёт данные мимо контекста модели.
Читается в ПОСЛЕДНЕЙ строке значения, там, где его пишет хост: `[mem:…]`,
Expand Down
4 changes: 2 additions & 2 deletions asset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ func TestAssetRefInsideListIsResolved(t *testing.T) {
c := &recordingCaller{out: "ok"}
a := &fakeAssets{content: map[string]string{"battery": "echo hello"}}
f := parseFlow(t, `
tools: ["k8s-job"]
tools: ["sandbox"]
assets:
battery:
kind: code
Expand All @@ -288,7 +288,7 @@ assets:
steps:
- name: run
call:
tool: k8s-job:run_job
tool: sandbox:run_job
args:
image: go-review
command: ["sh", "-c", {from: "asset:battery"}]
Expand Down
2 changes: 1 addition & 1 deletion call_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ func TestCallStepDeniedWhenFlowHasNoServers(t *testing.T) {
steps:
- name: sneaky
call:
tool: gitlab-write-prod:create_merge_request
tool: tracker-write:create_ticket
save_as: out
`)
_, _, err := ExecuteWith(context.Background(), f, Deps{Caller: c}, nil)
Expand Down
47 changes: 34 additions & 13 deletions cond.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import (
"unicode/utf8"
)

var condRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*(==|!=)\s*(.*?)\s*$`)
// The left operand of every condition is a REFERENCE — a name or a path into a
// value (`pod.status.containers[0].image`). Its shape is defined once, in
// path.go, and built into each of these: two spellings of one thing had already
// drifted apart here (substitution stopped at one field while a condition took
// any number of dots and a trailing one).
var condRe = regexp.MustCompile(`^\s*(` + RefPattern + `)\s*(==|!=)\s*(.*?)\s*$`)

// emptyCondRe — the "step produced nothing" condition: `var is empty` /
// `var is not empty`.
Expand All @@ -24,7 +29,7 @@ var condRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*(==|!=)\s*(.*?)
// they mean the same. This pattern showed up three times in a single live
// skill, and a meaning repeated in three places belongs to the engine rather
// than to the skill (same reasoning as ErrorPolicy).
var emptyCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s+is\s+(not\s+)?empty\s*$`)
var emptyCondRe = regexp.MustCompile(`^\s*(` + RefPattern + `)\s+is\s+(not\s+)?empty\s*$`)

// containsCondRe — the "the text names one of these" condition:
// `var contains a | b | c` / `var not contains a | b`.
Expand All @@ -45,7 +50,7 @@ var emptyCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s+is\s+(not\
// The list may come out empty here on purpose: `input contains` with nothing
// after it is recognised as this form so the error can say what is missing,
// rather than falling through to "the condition does not parse".
var containsCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s+(not\s+)?contains\b\s*(.*?)\s*$`)
var containsCondRe = regexp.MustCompile(`^\s*(` + RefPattern + `)\s+(not\s+)?contains\b\s*(.*?)\s*$`)

// numCondRe — the numeric comparisons: `var > 5`, `var >= req.limit`,
// `var < 0.5`, `var <= days`.
Expand All @@ -72,7 +77,7 @@ var containsCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s+(not\s+
// Deliberately NOT here: arithmetic (`a + b > c`, `len(x) > 0`). Those are
// expressions, and expressions are the door to skills that cannot be read from
// top to bottom.
var numCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*(>=|<=|>|<)\s*(\S.*?)\s*$`)
var numCondRe = regexp.MustCompile(`^\s*(` + RefPattern + `)\s*(>=|<=|>|<)\s*(\S.*?)\s*$`)

// numberRe — what counts as a number on either side of a comparison.
//
Expand All @@ -82,10 +87,6 @@ var numCondRe = regexp.MustCompile(`^\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*(>=|<=|>|<)\
// the silence these conditions are written to avoid.
var numberRe = regexp.MustCompile(`^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$`)

// nameRe — a variable name: the shape the left side of every condition takes,
// and the shape the right side of a comparison takes when it is not a literal.
var nameRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.]*$`)

// bracedLeftRe / bracedNameRe — a condition written the way substitution is
// written everywhere else: `{{pod.restartCount}} > 5`.
//
Expand All @@ -100,10 +101,18 @@ var nameRe = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_.]*$`)
// them by exactly two pairs of braces; now the error names the braces and
// prints the condition without them.
var (
bracedLeftRe = regexp.MustCompile(`^\s*\{\{\s*[a-zA-Z_][a-zA-Z0-9_.]*\s*\}\}`)
bracedNameRe = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_.]*)\s*\}\}`)
bracedLeftRe = regexp.MustCompile(`^\s*\{\{\s*` + RefPattern + `\s*\}\}`)
bracedNameRe = regexp.MustCompile(`\{\{\s*(` + RefPattern + `)\s*\}\}`)
)

// selectorRe — the form a path deliberately does not have: `containers[*]`, and
// filters after it. It appeared in the same measurement as the paths themselves
// (`containerStatuses[*].restartCount > 0`), which is why it gets a refusal of
// its own instead of falling into the list of allowed shapes: the author did
// not mistype the syntax, they asked for something the format declines to be —
// a query language. What they want is a loop, and the message says so.
var selectorRe = regexp.MustCompile(`\[\s*[*?]`)

// isBlank — "the step produced nothing useful": empty or a failure marker.
func isBlank(v string) bool {
t := strings.TrimSpace(v)
Expand All @@ -118,6 +127,11 @@ func isBlank(v string) bool {
// with nothing to look for can never fire, and a branch that can never run is
// not a branch, it is a hole the author cannot see.
func parseCond(cond string) (name, op, want string, err error) {
if selectorRe.MatchString(cond) {
return "", "", "", fmt.Errorf("condition %q: `[*]` picks MANY elements and a condition compares one — "+
"walk the list with `for_each` and put the condition in its body. "+
"A single element is `list[0]`", cond)
}
if bracedLeftRe.MatchString(cond) {
bare := strings.TrimSpace(bracedNameRe.ReplaceAllString(cond, "$1"))
// The suggestion is checked before it is offered: `{{a}} > пять` is two
Expand Down Expand Up @@ -151,7 +165,7 @@ func parseCond(cond string) (name, op, want string, err error) {
// Static, because it can never work: `count > пять` is wrong in the
// file, not at the moment the branch is reached. Validate calls this
// parser, so the skill is refused at load instead of mid-turn.
if _, ok := parseNumber(m[3]); !ok && !nameRe.MatchString(m[3]) {
if _, ok := parseNumber(m[3]); !ok && !refRe.MatchString(m[3]) {
return "", "", "", fmt.Errorf("condition %q: the right side of `%s` must be a number or the name of "+
"a variable holding one, and %q is neither", cond, m[2], m[3])
}
Expand Down Expand Up @@ -372,7 +386,10 @@ func (s *state) eval(cond string) (bool, error) {
// A large result lives in a variable as a preview plus a handle, and a
// condition reading the preview would answer about the first few hundred
// bytes while looking exactly as if it had answered about the value.
got := s.payload(name)
got, err := s.payload(name)
if err != nil {
return false, err
}
switch op {
case "is empty":
return isBlank(got), nil
Expand Down Expand Up @@ -407,7 +424,11 @@ func (s *state) operand(cond, ref string) (number, error) {
if n, ok := parseNumber(ref); ok {
return n, nil
}
got := strings.TrimSpace(s.payload(ref))
raw, err := s.payload(ref)
if err != nil {
return number{}, err
}
got := strings.TrimSpace(raw)
if isBlank(got) {
return number{}, fmt.Errorf("condition %q: `%s` is empty (or a marked failure) — there is nothing to "+
"compare. An empty variable is NOT zero: answering the comparison would make "+
Expand Down
Loading
Loading