skillengine

package module
v0.5.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 3, 2026 License: MIT Imports: 14 Imported by: 0

README

skill-engine

English · Русский

An engine for declarative programs for an LLM agent: a skill is described in steps, and control over the turn belongs to the code, not to the model. Steps are not the only form: a skill with only a playbook (a free-form instruction) is a full skill too (see "A prompt works as well").

No dependencies — production code runs on the standard library alone: the engine is embedded into someone else's application, and every dependency here would become a dependency of the embedder. YAML parsing is passed in as a parameter (the Unmarshal type), version comparison is implemented in place. The boundary is held by a guard test, imports_test.go, test imports included.

Why

A restriction written in words is a request: "do NOT call retract without confirmation", "run the check EXACTLY once". It is followed exactly as far as the model read before it started acting. In steps the same thing is expressed structurally: in the unconfirmed branch the retract call is not there, a call step cannot be repeated, a branch that does not apply does not run.

Measurements on live skills (tool calls / seconds, before → after): three skills of one catalogue went 18/95 → 2/6, 7/33 → 2/5 and 9/29 → 5/11. What they did is beside the point — what changed is who held the control flow.

A prompt works as well

Starting with structure is not required. A skill has two ways to describe its turn:

  • playbook — a free-form instruction: what to do and what to look at;
  • workflow — steps (steps, tools, vars, assets), i.e. everything below in this file.

The usual path is to write it as a prompt, debug it on live requests, and move into steps whatever is worth it: structure costs time, and there is no reason to pay for it before you know WHAT to structure. The measurements above are about that move.

While the move is under way both descriptions can sit side by side, with the mode field switching between them so their outcomes can be compared on live requests — instead of deleting half the work just to check:

mode workflow playbook what runs
unset present present workflow — structure outranks prose
unset present workflow
unset present playbook
unset error: the skill describes no turn
workflow present any workflow
workflow present error: the mode is set, there are no steps
playbook any present playbook
playbook present error: the mode is set, there is no text

An empty half under an explicit mode is a refusal, not a fallback to the other one: switch the mode to playbook, forget to write the text, and you would otherwise get a clean run over the old steps and the conclusion "in playbook mode it works the same" — from a turn the playbook never took part in. The first two errors are caught statically by the schema (if/then on mode); the whole table is implemented by ResolveMode.

The engine reads only workflowFlow has no playbook field. A skill without steps is run by the embedding application its ordinary way: it is a prompt, and the engine has nothing to do there. ResolveMode lives here because "which of the two descriptions is in effect" is format semantics: were it different in every host, skill portability would end silently.

Example

tools: [staging, exec]
steps:
  - name: understand                  # parse the request into fields
    instruction: |
      Request: {{input}}
      cluster — which cluster is named; namespace — the namespace name.
    tools: []                         # this step needs no tools
    model: vllm/gemma-4-e4b
    response_schema:
      type: object
      properties:
        cluster: {enum: [staging, sandbox]}
        namespace: {type: string}
      required: [cluster, namespace]   # see "Format pitfalls"
    save_as: req

  - name: fetch_pods                  # a call WITHOUT generation
    call:
      tool: kubectl_get
      args: {namespace: "{{req.namespace}}", resourceType: pod}
    on_server: "{{req.cluster}}"
    save_as: pods

  - name: report                      # a step without save_as writes the turn's answer
    instruction: |
      Pods: {{pods}}
      Answer: pod name → status.
    tools: []

Step kinds

step what it does
instruction generation by the model; tools sets the RADIUS — an empty list means "no tools"
call a tool call without generation; arguments in YAML
set assigning a variable
switch / if branching on a variable's value
for_each a loop over a collection, collect gathers the body's results
parallel parallel branches; <collect>.skipped — those that did not run because of when
delegate delegating to another skill (the application decides how to execute it)
exit "matched by mistake" — the turn returns to its ordinary path

Branching on the words of a request

A condition compares with ==, !=, is [not] empty — and with contains:

- name: pick_dessert
  when: "input contains десерт | сладк | dessert"
  call: {tool: "recipes:search", args: {section: dessert}, save_as: found}

- name: nothing_named
  when: "input not contains десерт | сладк | напит | чай | горяч | салат"
  exit: {reason: "the request names no section of the menu"}

Any ONE alternative is enough, and an alternative may contain spaces. This replaces a whole kind of step: a classifier whose only job is "which of these words did the request name" already carries the mapping in its own text, so the decision is deterministic and the model is there only to apply it. Measured on ten live requests — the model at temperature 0 got 5 of 10, the same dictionary in a condition 10 of 10, and three rewordings of the instruction did not move the ceiling. Every miss was one kind: falling back to a default and dropping what the request had NAMED.

Two properties are worth knowing before you write a dictionary:

  • a match must begin where a word starts, and that is the default rather than an option — a false match is nearly impossible to debug because the condition looks right (пуст inside перезапустить, search inside research). Note that Go's \b is ASCII-only and would not have helped here at all;
  • the end is free, so an alternative matches a word that starts with it. That is what lets a dictionary hold ROOTS — заказ finds заказы, заказа, заказу — and a dictionary of roots is why the format needs no stemming, which would be a guess about a language the engine does not know. The cost: a too-short root collides (ком finds компонентах), and the linter's W18 warns when one alternative is already covered by a shorter one.

Regular expressions are deliberately absent: they would make skills unreadable and open the door to catastrophic backtracking.

Shared step settings

What repeats across a catalogue is usually not the step but its envelope. A profile is that envelope under a name; anything the step spells out itself wins, and sampling is replaced whole rather than merged key by key:

profiles:
  classifier:
    model: small/model
    sampling: {temperature: 0}
    tools: []                    # an empty SET — the guard travels with it
steps:
  - name: understand
    profile: classifier
    instruction: …               # the work stays per-step
  - name: judge
    profile: classifier
    sampling: {temperature: 0.2} # one field, overridden here only
    instruction: …

When a step comes back empty

An empty result used to be indistinguishable from content downstream: the next step honestly ran ok on an empty input and the turn produced an empty answer wearing the look of a successful one. on_empty says what the emptiness means:

value what happens
continue legal, the flow moves on — the default, and the old behaviour
fail the step counts as failed; on_error decides from there
retry run it again on_empty_retries times (1..5); still empty is then fail
use store on_empty_value instead (supports {{var}})

Empty means an empty string after trimming, judged on the value the step would store — with one_of an ambiguous answer produces text and stores nothing, and it is the stored value that flows on. It works on call steps too, except retry: a call cannot be repeated.

Variables

  • 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.
  • <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.
  • {{asset:name}} substitutes an asset into TEXT (it passes through the context), {from: "asset:name"} — by REFERENCE (it does not).
  • A step without tools reads a variable whole. A large result reaches a model as a fragment plus the host's note saying how to read the rest — which a step with tools follows by calling, and a step without tools cannot follow at all. Told to make a call it has no way to make, a model writes the call out as its answer (a live turn ended with the arguments of a memory call printed where a report was meant, and the step was recorded ok). So the addressee of a substitution is three, not two: a script or a call argument gets the payload, a model that CAN fetch more gets the fragment and the note, and a model that cannot gets the whole value.
  • <collect>.skipped — branches skipped because of when. Without it the answering step cannot tell "the source answered nothing" from "we never went to the source".

The contract with the application

out, outcome, err := skillengine.ExecuteWith(ctx, flow, skillengine.Deps{
    Runner:   …, // executes an instruction step (generation)
    Caller:   …, // executes a call step (a tool)
    Delegate: …, // executes a delegate step
    Assets:   …, // resolves asset content                    (optional)
    Memory:   …, // returns a full result by its .mem handle   (optional)
    OnStep:   …, // a step's trace RIGHT AFTER it, not in bulk (optional)
}, vars)
  • out — the variables produced by the steps; the vars passed in do not end up in the result. Otherwise a flow that did not fill in the answer hands the caller its own input — live case: a user got a transcript of their own messages in chat instead of an answer.
  • Outcome.Steps — the trace of every step (name, kind, outcome, reason, duration, number of calls and failures);
  • Outcome.Skipped — steps not executed because of when;
  • Outcome.AnsweredByinstruction or call: what wrote the answer. Needed so that post-processing does not rewrite a script's deterministic output.

The engine logs nothing, persists nothing and goes nowhere: the input and the steps' output are the caller's data. Everything visible from outside is handed over as a structure (Outcome) and through callbacks (OnStepStart — before a step, for showing work to a human; OnStep — right after). Turning that into telemetry is the embedding application's job.

The format's schema is skill.schema.yaml — the source of truth, embedded as SchemaYAML, with SchemaSummary giving the compact version to hand a model. SchemaRU / SchemaSummaryRU are the same in Russian: also embedded, so go mod vendor carries them to whoever shows the schema to a skill author. A test keeps the two structurally identical, so only the prose differs, and validation always goes against the English one.

The format version is in version.go; CheckEngineVersion rejects both a description from the future and one of a foreign major: the latter would parse without a single complaint, silently losing fields the structs no longer have. Since skills live in a user's storage and are not updated with a deploy, the edits that a major needs ship with the code that makes them:

out, changed, err := skillengine.Migrate(raw)

Migrate edits the file as text — comments, key order and block scalars survive — and does not validate the result; parse and validate it as usual afterwards. It takes a skill file as the format defines it, one YAML document: if you keep skills inside a wrapper of your own (front matter, a markdown body), strip it before the call and put it back after — a wrapped input is refused, not guessed at. Format changes and what each migration does are in CHANGELOG.md.

A skill file is more than its steps, so the whole file has a type too: ParseSkill(raw, unmarshal) reads header and description into a Skill, and Skill.Validate() checks the version, the header and the workflow in one go. Every field of that header is already described by the schema — that is, it belongs to the FORMAT — and yet each embedder used to declare its own struct for it and re-derive the same rules; two copies of a contract drift, and the field the engine gained is silently dropped by the copy.

Invariants paid for with live failures

  • A failure must be loud. degraded is set on a step with no text, on a fork where no branch ran, on a switch with no match and an empty default, on a loop with failed iterations, on a truncated answer. A silent failure here looks like success: the turn answers with an internal variable, and that reads as a finished answer.
  • One resolver per reference, and the addressee picks the form. A variable holds what the host would show the MODEL — a large result arrives as a preview with a [mem:id] handle. A tool argument, a loop's collection and a condition need the whole thing with the note stripped. Every consumer used to sort that out for itself and one always forgot: the class fired four times in a day at an embedder, each time somewhere new. Now there are two ways to ask — expand for the model, payload for data — and a guard test fails the build if anything reads the variable map directly.
  • A mechanism added to the model's path must appear on the call path too. Nine misses in a row, each found by a live failure: empty arguments, {from:} references, delivery, retries, request normalisation, provenance, argv repair, builtin tools, cross-turn memory.
  • Knowledge is expensive in a step WITH TOOLS: an asset rides along into EVERY generation of the react loop. The cure is splitting it into "decide" (knowledge, no tools) and "do" (tools, no knowledge).

Format pitfalls

  • required is the only lever. A strict schema enforces only what is listed: a field in properties but not in required may legitimately not be sent by the model. Live measurement: confidence did not arrive ONCE out of 26 findings, while 16 of them wrote the number in words inside the text.
  • A string field needs maxLength. Otherwise the model writes until the token ceiling and breaks off mid-line, taking the whole document with it. The grammar holds the limit: maxLength: 600 → exactly 600 characters and valid JSON.
  • for_each.in takes a variable NAME, not a template. in: "{{parts}}" yields zero iterations and reports success (the engine now rejects that).
  • The exec envelope is not the payload. {{findings}} is {"exit_code":…,"stdout":"…"}; a loop and the arguments need .stdout.

The joints between steps are where programs break, and static checks catch them more cheaply than a run does. Flow.Validate is called before execution and rejects what used to be reported as success; every new such class is closed off by a check in validation rather than by a paragraph here. Running it over descriptions before execution is worth it too — in CI, when a skill is written.

The library ships no words

An agent about a kitchen, one about a car fleet and one about a warehouse share this format and nothing else — not a domain, not a house style, not a language. So the engine knows only the words it WRITES itself: the failure markers it records (ERROR:, DENIED:) and the working-memory handle it defines ([mem:id]). Everything else is declared by whoever embeds it:

deps := skillengine.Deps{
    Runner: ..., Caller: ...,
    Vocabulary: skillengine.Vocabulary{
        // What YOUR model writes before naming its choice — used by `one_of`
        // to lift a decision out of prose.
        DecisionMarkers: []string{"Result:", "Résultat:", "结论:"},
        // How YOUR host marks a result it shortened — stripped before a value
        // reaches a tool argument, a loop or a condition.
        TruncationNotes: []string{"[shortened:"},
    },
}

An empty field is not a mistake: it means "my application has no such words", and the mechanism that needed them steps aside. It never guesses. Leaving DecisionMarkers empty costs one of five ways one_of normalises an answer, and the narrowest one — an exact answer, a single value mentioned and a value mentioned strictly more often all work without any words at all. Markers decide only a tie, and there the result is empty rather than wrong; the step's trace then names the field, so a missing declaration is visible instead of being inferred from a quiet default.

The same applies to the linter: Options.EmptyWords and Options.FreeTextFields carry the words W16 and W13 need, and without them those rules skip with a recorded reason rather than passing a skill as clean.

Checking a skill before it runs

Validate refuses what cannot run. What runs badly is the business of lint, a subpackage under the same no-dependency rule:

rep, err := lint.Lint(raw, facts, lint.Options{Unmarshal: yaml.Unmarshal})

27 rules, every one of them paid for by a broken turn, and every one about a defect that stays QUIET: a loop collecting into a variable nobody writes gathers nothing and reports success, a typo in a variable's name resolves to an empty string, a required field the instruction allows to be empty sends the model into whitespace up to the token ceiling.

The rules that need to know the installation — which servers are up, which tools they carry, which built-in tools exist — take those facts from the embedder and skip with a recorded reason when they are not given: a partial check must never look like a clean one. Severity is the library's, the gate is yours.

The rule table, what deliberately stays with the embedder, and the one limitation worth knowing before relying on it are in lint/README.md.

Tests

example_flow_test.go — runnable examples of the format, a good first entry point. examples_test.go parses every file from examples/ with the engine: an example that stopped parsing is worse than a missing one — it teaches the wrong thing.

Documentation

Overview

Package skillengine executes a declarative skill description: a sequence of steps, each with its own tool set, its own call budget and its own policy on failure.

Why. A restriction written in words is followed probabilistically by a model: a measured example — the rule "do not go to this source" produced 53 attempts and not one successful read in a month. The absence of that source from a step's tool set is something it cannot fail to follow.

The shape is borrowed from AgentSPEX (arXiv 2604.13346, Apache 2.0), a YAML language for specifying agent workflows. The FORM is taken, not the code: that one is Python with its own harness and its own sandbox. The subset was chosen empirically: three live skills written out in this syntax used steps, branching and variables — and never parallel/while/gather.

The package is written to be DETACHABLE: the domain (tools, RBAC, telemetry) lives behind the interfaces below, and nothing specific to a particular agent is imported inside.

Index

Constants

View Source
const AnswerVar = "answer"

AnswerVar — the variable the turn's answer is taken from. A step that did not name a save_as writes here: not naming one is the ordinary way of saying "this is the final step".

View Source
const BuiltinServer = "builtin"

BuiltinServer — the pseudo-server for the embedding application's built-in tools (`call: {tool: "builtin:run_script"}`).

It exists so that a deterministic chain can be expressed with `call` steps instead of being handed to the model: the chain search_tickets → count_per_day → chart_timeseries is the same every time, and three model calls for it are three chances to mix up a handle or an argument.

View Source
const DefaultEmptyRetries = 1

DefaultEmptyRetries — how many times EmptyRetry runs the step again when the skill does not say. One, because "retry" means once unless stated otherwise.

View Source
const DefaultMaxIterations = 10

DefaultMaxIterations — the loop ceiling when the skill did not set its own.

View Source
const EngineVersion = "2.2.1"

EngineVersion — the format version supported by this engine.

major — incompatible change (a skill of a previous major does not load
        without migration);
minor — an optional field was added (a skill using it requires an engine
        no older than that minor);
patch — engine fixes, the format did not change.
View Source
const LegacyEngineVersion = "1.0.0"

LegacyEngineVersion — what counts as the declared version when the field is absent (skills written before it was introduced).

From major 2 on, such a skill is rejected rather than executed: it was written under 1.x rules, and parsing it silently would drop fields that no longer exist in the structs.

View Source
const MaxEmptyRetries = 5

MaxEmptyRetries — the ceiling on that count. The format has no unlimited anything on purpose: a step retried without a bound brings back exactly the runaway the format exists to prevent. Five matches the ceiling already chosen for fetching an external asset.

View Source
const MemSuffix = ".mem"

MemSuffix — the suffix of the variable holding a working-memory handle: the result of a `save_as: pods` step puts the handle into `pods.mem`.

View Source
const SkippedSuffix = ".skipped"

SkippedSuffix — the suffix of the variable holding the list of skipped branches: `collect: findings` puts them into `findings.skipped`.

Variables

View Source
var ErrDenied = errors.New("skill-engine: denied")

ErrDenied — a permission refusal. Returned by a Runner so the policy can tell "not allowed" from "broke": retries and workarounds are pointless for the former and sometimes sensible for the latter.

View Source
var ErrExit = errors.New("skill-engine: exit")

ErrExit — the flow was stopped by an `exit` step. NOT an execution error: the caller must tell it from a failure, because the reaction is the opposite — not "show what we gathered" but "this skill does not fit, take the ordinary path".

View Source
var SchemaRU string

SchemaRU — the same contract in Russian.

EMBEDDED, not merely present in the repository, and that is the whole point: an embedder shows the schema to whoever writes the skill, and a file that is not embedded does not travel — `go mod vendor` copies only what the build references, so a translation living beside the package would simply not arrive. A field the author cannot read is a field the author does not use.

A test keeps this structurally identical to SchemaYAML; only the prose differs, and SchemaYAML stays the source of truth for validation.

View Source
var SchemaYAML string

SchemaYAML — the skill format contract (JSON Schema written in YAML).

It lives next to the engine, not only in the spec: skills are validated against it on write, and it is handed to the model when it writes a skill on a human's request. The spec is documentation; the source of truth for code is here.

Functions

func AssetRefsInArgs

func AssetRefsInArgs(args map[string]any) []string

AssetRefsInArgs lists assets passed by reference ({from: "asset:name"}) — that is, past the model's context. The difference from AssetRefsInText is not stylistic: it decides whether the model sees the content.

func AssetRefsInText

func AssetRefsInText(text string) []string

AssetRefsInText lists assets substituted into text ({{asset:name}}).

func CheckEngineVersion

func CheckEngineVersion(declared string) error

CheckEngineVersion reports whether the engine can execute a skill.

The refusal is EXPLICIT rather than a silent run under the old rules: a skill that needs fields from a future version would, on a quiet fallback, work "somehow" — that is, produce a plausible wrong result instead of an honest error. Same class of failure as the model bridge where a dropped field vanished instead of being rejected.

func CompareSkillVersions

func CompareSkillVersions(a, b string) (int, error)

CompareSkillVersions compares versions of ONE skill: >0 when a is newer.

Needed where a content hash is compared today: a hash answers "did it change" but not "which is newer", so it supports neither a deliberate rollback nor resolving a divergence.

An empty version counts as the oldest: a skill that did not declare one must not overwrite a skill that did.

func CondContains added in v0.3.0

func CondContains(cond string) ([]string, bool)

CondContains returns the words a `contains` condition looks for, and whether the condition is of that form at all.

Exported for the same reason as CondVar — whoever reads a description asks the engine what a condition means instead of re-deriving the syntax. A second parser of it drifts on the first change, and here it would drift into reporting on conditions that do not exist.

func CondVar added in v0.1.0

func CondVar(cond string) (string, bool)

CondVar returns the variable a branch condition tests, and whether the condition parses at all.

Exported so that whoever reads a description — a linter, an editor, a visualiser — asks the engine which name a condition depends on instead of re-deriving the grammar from the docs. A second parser of the same syntax drifts on the first change: the left operand is a NAME while the right one is a literal, and a reader that misses the difference reports the value as a missing variable.

func ContainsWord added in v0.3.0

func ContainsWord(text, word string) bool

ContainsWord reports whether text names word — case-insensitively, and only where a WORD STARTS.

Two decisions, both paid for by live failures.

Case folding is Unicode-wide, not ASCII: the requests these conditions read are written by people, in whatever case and whatever script they use.

The match must begin at a word start, and this is the DEFAULT rather than an option — the author of a skill will not think about it, while a false match is nearly impossible to debug because the condition looks right. Two live burns, both of them findings that looked genuine: «пуст» matched inside «перезапустить», and a rule about emptiness fired on a step about restarting. Note that Go's `\b` is ASCII-only and does not work on Cyrillic at all, which is why the check is written by hand.

The END of the match is deliberately NOT anchored: an alternative matches a word that STARTS with it. That is what makes a dictionary of ROOTS work — «заказ» finds «заказы», «заказа», «заказу» — and a dictionary of roots is why this engine needs no stemming, which would be a guess about a language it does not know. The cost is that a too-short root collides: «ком» finds «компонентах». That is a dictionary problem with a dictionary fix — a longer root — and the linter warns about an alternative made redundant by a shorter one.

func Migrate

func Migrate(raw []byte) ([]byte, bool, error)

Migrate rewrites a skill file into the format this engine speaks and reports whether anything changed.

The file is edited as TEXT rather than re-serialised. A skill is a hand-written document: its comments carry the reason a field exists and the failure that paid for a limit (see examples/), and a round trip through a YAML marshaller drops every one of them, reorders the keys and reflows the block scalars. A migration that silently strips the comments would trade one silent loss for another.

What it does for 1.x → 2.x:

an asset's `lang: python`  →  `params:` / `  lang: python`
skill_engine_version       →  the current major's baseline (added if absent)

The input is a skill file as the FORMAT defines it: one YAML document. An embedder that wraps skills in something of its own — front matter, a markdown body, a bundle of several documents — unwraps them before calling and wraps the result back. Such an input is refused rather than guessed at: the wrapper belongs to the embedder, and teaching the library about it would be exactly the host-specific knowledge this package is built without.

It does NOT validate the result — parse and validate it as usual afterwards. A skill already on the current major is returned untouched with false.

func SchemaSummary

func SchemaSummary(unmarshal Unmarshal) string

SchemaSummary — a compact reference for the format in English: the fields and the first line of each description.

The full schema is 56 KB. Handing that to a model means spending context on a reference book: the same kind of suffocation the format protects against by truncating tool results. A skill author needs the list of fields and what each means; the details are in the spec, for humans.

func SchemaSummaryRU

func SchemaSummaryRU(unmarshal Unmarshal) string

SchemaSummaryRU — the same reference in Russian, for an embedder whose skill authors (and whose skill-writing model) work in Russian.

A separate function rather than SchemaSummaryOf(lang string): a language code is a string, and a string can be misspelled into a silent empty result. Two names cannot.

func SplitToolRef

func SplitToolRef(ref string) (server, tool string, ok bool)

SplitToolRef parses "server:tool". The separator is the FIRST colon: tool names contain colons, server names do not.

func ValidateSkillName added in v0.1.0

func ValidateSkillName(name string) error

ValidateSkillName checks a skill's name against the format.

The name is not decoration: it is the file's name, the key a delegate step refers to, and the label every measurement of the skill is grouped by. A name that differs from its file's by a capital letter looks the same in a report and is a different skill to the code.

Types

type Asset

type Asset struct {
	Kind   string `yaml:"kind,omitempty"`
	Source string `yaml:"source,omitempty"`
	// Content — the content itself, for source: inline.
	Content string `yaml:"content,omitempty"`
	// Ref — the address for external sources: "project@branch:path" |
	// "path in file storage" | "server:tool".
	Ref string `yaml:"ref,omitempty"`
	// Args — MCP call arguments for source: mcp. They support {{var}}.
	Args map[string]any `yaml:"args,omitempty"`
	// Params — whatever only makes sense for a SPECIFIC kind: the language for
	// code (giving a linter a reason to check syntax before production), the
	// format for text, the schema for data.
	//
	// A map rather than struct fields, because kinds are the APPLICATION's
	// vocabulary and the engine does not know them (see Kind above). A field
	// per kind would mean the library grows with every foreign kind while
	// sitting empty on every other asset: that is exactly how `lang` lived
	// here, needed by one kind out of four.
	//
	// The engine does not look inside and does not check the shape — it hands
	// this to the resolver as is.
	//
	// Which means VALIDATING THE KEYS IS THE RESOLVER'S JOB, and it is not
	// optional. An open map buys the format its independence from other
	// people's kinds at a price: `params: {langauge: python}` passes both the
	// parser and the schema, where a misspelled `lang` field could not exist at
	// all. Nobody but the resolver knows which keys are legal for a given kind,
	// so nobody else can catch that — and unread, a typo means the linter never
	// checks the syntax and the failure surfaces in production.
	Params map[string]any `yaml:"params,omitempty"`
	// Deliver — where the OUTPUT of the tool that consumed the asset goes:
	// reply — the output becomes the turn's answer; file — delivered as a
	// file; empty — returned to the step.
	//
	// It exists because the model forgets to attach the fragile delivery
	// argument (live failure: the render worked, the file never reached the
	// user). The skill author declares the route, the bridge pins it down.
	Deliver string `yaml:"deliver,omitempty"`
	// Description — what the asset is for; read by the HUMAN editing the
	// skill. Especially needed for external ones: their content is not visible
	// in the file.
	Description string `yaml:"description,omitempty"`
	// Fetch — the retrieval policy for external sources.
	Fetch *Fetch `yaml:"fetch,omitempty"`
}

Asset — a payload declared in a skill.

type AssetResolver

type AssetResolver interface {
	Resolve(ctx context.Context, name string, a Asset) (string, error)
}

AssetResolver fetches an asset's content. The domain (repositories, file storage, MCP) lives behind the interface — the engine knows nothing of it.

type Call

type Call struct {
	// Tool — which tool to call, in the form "server:tool".
	Tool string `yaml:"tool"`
	// Args — the call's arguments. Values support {{var}} substitution; it
	// applies to strings ONLY, the structure stays as written.
	Args map[string]any `yaml:"args,omitempty"`
	// SaveAs — the variable for the result ("" = the result is not stored).
	SaveAs string `yaml:"save_as,omitempty"`
	// OnError — what to do on failure. Default is Abort, as for Run.
	OnError ErrorPolicy `yaml:"on_error,omitempty"`
	// OnEmpty — what an empty tool result means (see empty.go). A tool that
	// returned nothing is the same class as a model that said nothing, so the
	// field exists on both paths; `retry` is the exception, refused here
	// because a call cannot be repeated.
	OnEmpty EmptyPolicy `yaml:"on_empty,omitempty"`
	// OnEmptyValue — what to store instead, for OnEmpty: use. Supports {{var}}.
	OnEmptyValue string `yaml:"on_empty_value,omitempty"`
}

Call — a direct tool call, WITHOUT the model.

Why. A step executed by the model costs two generations even when there is nothing to decide: one to decide to call the tool, another to retell its result. Measured: 6 generations for 2 tool calls. Meanwhile the call itself is often DETERMINISTIC — all the arguments already sit in the flow's variables (computed by earlier steps or by Set).

The difference from Run: there is no instruction, no choice and no retelling — the tool's result lands in the variable verbatim. A whole class of "the model called the tool in the wrong envelope" errors disappears with it.

Permissions are NOT bypassed: the call goes the same way as a call from the model and passes the same checks. A step cannot reach a tool the caller is not allowed.

type Delegate

type Delegate struct {
	Skill   string      `yaml:"skill"`
	Task    string      `yaml:"task"`
	SaveAs  string      `yaml:"save_as,omitempty"`
	OnError ErrorPolicy `yaml:"on_error,omitempty"`
}

Delegate — hand the work to another skill.

This is what composite skills do: incident triage calls ticket creation, a search skill calls a search skill and a reference skill. Without such a step a composite handed a description would execute as an ordinary flow and lose delegation entirely — that is, stop doing its job.

The radius is the radius of the CALLED skill: it declares its own servers, and delegation does not widen the caller's access.

type Deps

type Deps struct {
	Runner   Runner
	Caller   ToolCaller
	Delegate SkillDelegate
	// Assets resolves the content of declared payloads.
	Assets AssetResolver
	// Memory returns a tool's full result by its working-memory handle.
	//
	// Field substitution needs it: the host stores a PREVIEW in the variable
	// (truncated when large), while `{{var.field}}` parses the variable as
	// JSON. A truncated preview cannot be parsed at all, even though the whole
	// thing sits in memory under the same handle. Without this the field
	// silently goes empty — and the call leaves with an empty argument.
	//
	// Optional: nil = fields are only available on a result that was not
	// truncated.
	Memory MemoryReader
	// Vocabulary — the words of THIS application. The engine ships none of its
	// own: agents that share this format do not share a language, a domain or a
	// house style, and a word list baked in here would be one application's
	// habit imposed on everyone else's. See the Vocabulary type.
	Vocabulary Vocabulary
	// OnStepStart is called BEFORE a step. Needed by anyone showing work to a
	// human: a step that runs for 14 seconds emits no event until it
	// finishes, and there is nothing to show all that time.
	OnStepStart func(name, kind string)
	// OnStep is called RIGHT AFTER each step, not at the end of the flow.
	//
	// Otherwise the caller learns about all the steps at once, when the turn
	// is already over: the progress post shows a finished list instead of work
	// as it happens, and the user stares at "brewing…" for nine seconds.
	OnStep func(StepTrace)
}

Deps — the executors the engine receives from outside. A struct rather than an argument list: there are several, and adding one more must not force an edit at every call site.

type EmptyPolicy

type EmptyPolicy string

EmptyPolicy — what an empty result means for a step.

A dictionary of outcomes rather than allow_empty: true/false, and deliberately the SAME SHAPE as ErrorPolicy: a boolean would have to grow the first time "empty is a valid answer" (there really were no findings) needs telling apart from "empty is a failure" — and it is already three outcomes, counting "empty, but use this instead". One vocabulary beats two.

const (
	// EmptyContinue — an empty result is legal, the flow moves on. The default,
	// and exactly what the engine did before this field existed: an instruction
	// step is still traced `degraded`, a call step still `ok`. Declaring it
	// changes nothing; it is there so a skill can say the silence is intended.
	EmptyContinue EmptyPolicy = "continue"
	// EmptyFail — the step counts as failed, and OnError decides from there.
	EmptyFail EmptyPolicy = "fail"
	// EmptyRetry — run the step again, up to OnEmptyRetries times. Instruction
	// steps only: repeating a `call` would break the format's promise that a
	// call step cannot be repeated, and a retried call with a side effect is a
	// second ticket, a second merge request, a second e-mail.
	//
	// Still empty once the retries are spent → treated as EmptyFail: the author
	// asked to retry because empty was not acceptable, and continuing then is
	// the very silence this exists to break. Pair it with `on_error: continue`
	// to retry and then tolerate.
	EmptyRetry EmptyPolicy = "retry"
	// EmptyUse — store OnEmptyValue instead and carry on. Supports {{var}}.
	EmptyUse EmptyPolicy = "use"
)

type ErrorPolicy

type ErrorPolicy string

ErrorPolicy — the reaction to a step's failure.

Three classes of failure are told apart deliberately: a permission refusal (the tool exists but the caller is not allowed it) calls for different behaviour than an empty result. The policy was repeated verbatim by three different skill authors — a sure sign it belongs to the engine rather than to the skill.

const (
	// PolicyAbort — stop the flow (default).
	PolicyAbort ErrorPolicy = "abort"
	// PolicyContinue — record the failure into the step's variable and move on.
	// This is how "no permission — say so and continue with what is available"
	// is expressed.
	PolicyContinue ErrorPolicy = "continue"
	// PolicySkip — skip the rest of the current branch without aborting the flow.
	PolicySkip ErrorPolicy = "skip"
)

type Exit

type Exit struct {
	// Reason — why we left. Goes into the event and the log: these strings are
	// what routing misses are diagnosed from.
	Reason string `yaml:"reason,omitempty"`
}

Exit — leaving the skill: the flow stops and the turn returns to its ordinary path.

Needed because steps execute in full whatever they are given: a step has no reason to doubt that the skill was chosen correctly. Exit makes the doubt expressible.

The solution is not a new mechanism but a new STEP KIND: the program already has a classifier, and all it needs is a "not my case" value and a branch with exit.

type ExitError

type ExitError struct{ Reason string }

ExitError carries the reason for leaving up to the caller.

func (*ExitError) Error

func (e *ExitError) Error() string

func (*ExitError) Is

func (e *ExitError) Is(target error) bool

type Fetch

type Fetch struct {
	// TTL — how long the content counts as fresh. Empty/0 — fetch every time.
	TTL time.Duration `yaml:"ttl,omitempty"`
	// Timeout — the ceiling on ONE attempt. A turn must not hang on someone
	// else's downtime.
	Timeout time.Duration `yaml:"timeout,omitempty"`
	// Retries — retries beyond the first attempt, with exponential backoff.
	// Only transient failures are retried: retrying a 404 will not change the
	// outcome.
	Retries int `yaml:"retries,omitempty"`
	// OnUnavailable — what to do when retrieval failed:
	// fail (default) | stale — the previous copy | empty — nothing.
	OnUnavailable string `yaml:"on_unavailable,omitempty"`
}

Fetch — how an external asset is retrieved.

An external asset is fetched DURING the turn rather than from a periodic cache: the point of an external source is freshness. A documentation page gets edited, a service list changes; handing out yesterday's copy cancels the reason the asset was made external. The cost is network on the hot path, and it is treated with this policy rather than a ban.

type Flow

type Flow struct {
	// Steps run sequentially; branching happens inside a step (Switch/If).
	Steps []Step `yaml:"steps"`
	// Tools — the tool set available to the whole flow. A step can only narrow
	// it, never widen it: widening would make the restriction meaningless.
	Tools []string `yaml:"tools,omitempty"`
	// Vars — initial values of the flow's variables.
	Vars map[string]string `yaml:"vars,omitempty"`
	// Assets — named payloads passed to tools by reference (see asset.go).
	// Declared in the skill rather than in a step: the same payload is often
	// consumed by several steps.
	Assets map[string]Asset `yaml:"assets,omitempty"`
	// Profiles — named sets of step parameters (see profile.go). Folded into
	// the steps that name them before validation, so nothing downstream knows
	// they existed.
	Profiles map[string]Profile `yaml:"profiles,omitempty"`
}

Flow — a parsed description: the set of steps executed in order.

func (*Flow) DeclaredTools

func (f *Flow) DeclaredTools() []ToolRef

DeclaredTools lists the tools the flow may call.

A pure function over the description: permissions are checked by the caller, the engine knows nothing of them. The point is to learn about a refusal BEFORE the first generation rather than halfway through, with steps already burned.

func (*Flow) Validate

func (f *Flow) Validate() error

Validate checks a description before execution: an empty flow, a step with no action, two actions in one step, a reference to an unknown policy.

type ForEach

type ForEach struct {
	// In — the variable holding the collection. An array (say, from a structured
	// answer) is iterated element-wise; a string, by its non-empty lines. The
	// latter covers the observed case of "a list produced by a tool".
	In string `yaml:"in"`
	// As — the name of the item variable inside the loop body.
	As    string `yaml:"as"`
	Steps []Step `yaml:"steps"`
	// Collect — the variable the iterations' results are gathered into.
	Collect string `yaml:"collect,omitempty"`
	// MaxIterations — the ceiling. Required IN SPIRIT even when not set
	// explicitly: a loop over a collection of unknown length is a straight road
	// to a runaway, and the session budget will stop it only after the turn has
	// been burned.
	MaxIterations int         `yaml:"max_iterations,omitempty"`
	OnError       ErrorPolicy `yaml:"on_error,omitempty"`
}

ForEach — repeat steps for every item of a collection.

Live cases that cannot be expressed otherwise: "for EACH of the 5 services find the repository and the commit", "for every service of the release from versions.yaml", "for every service across all the tables". The list's length is not known in advance — it cannot be unrolled into fixed steps.

type If

type If struct {
	Cond string `yaml:"cond"`
	Then []Step `yaml:"then"`
	Else []Step `yaml:"else,omitempty"`
}

If — branching on a condition of the form "var == value" / "var != value".

type MemoryReader

type MemoryReader interface {
	Get(id string) (string, bool)
}

MemoryReader returns a tool's full result by its working-memory handle.

An interface rather than the host's type: the package must stay detachable, and all it knows about the application's working memory is exactly this — "a string handle can be turned into a string".

type Mode

type Mode string

Mode — how a skill is executed.

const (
	ModeWorkflow Mode = "workflow"
	ModePlaybook Mode = "playbook"
)

func ResolveMode

func ResolveMode(declared string, hasWorkflow, hasPlaybook bool) (Mode, error)

ResolveMode decides which description to run.

declared is the skill's `mode` field; an empty string means it is unset. The two flags follow in the order "steps, text": whether a non-empty `workflow` and a non-empty `playbook` are present.

The default (field unset) is steps when there are any: structure outranks prose, and that is also the direction of migration — move the prompt into steps and the turn follows them without touching configuration.

An explicit mode with an empty half is an ERROR, not a fallback to the other one. A silent fallback looks like success: switch the mode to `playbook`, forget to write the text, and you get a clean run over the old steps and the conclusion "playbook works the same" — drawn from a turn the playbook never took part in.

type Outcome

type Outcome struct {
	// Steps — the trace of every executed (and skipped) step.
	Steps []StepTrace
	// Skipped — steps not executed because of a false `when`. Empty for a flow
	// without conditions; non-empty means the task matched only PARTIALLY, and
	// this is the only way to notice that.
	Skipped []string
	// AnsweredBy — the kind of step that wrote the turn's ANSWER:
	// "instruction" (the model wrote the text) or "call" (a tool printed it).
	// The difference is not cosmetic: a model's answer is a draft and may
	// legitimately be rewritten "in voice"; the output of a deterministic
	// render is not a draft, and rewriting it means losing exactly the
	// guarantees it was made deterministic for.
	AnsweredBy string
}

Outcome — what happened to the flow beyond the variables.

func ExecuteWith

func ExecuteWith(ctx context.Context, f *Flow, deps Deps, vars map[string]string) (map[string]string, Outcome, error)

ExecuteWith walks the flow step by step and returns the variables PRODUCED by the steps. The input ones (`vars`) are not included: the caller has them already, whereas mistaking them for the result is expensive. Live class of failure: a flow that did not fill in the answer handed the caller its longest variable, which turned out to be the conversation history passed in — the user got a transcript of their own messages instead of an answer.

An error is returned only when the flow was aborted (PolicyAbort or an executor failure). A step failing under another policy is not an error — it is recorded into the variables, deliberately: "no permission to read the attachment" should lead to an answer based on what is available, not to a failed turn.

type Parallel

type Parallel struct {
	Branches [][]Step    `yaml:"branches"`
	Collect  string      `yaml:"collect,omitempty"`
	OnError  ErrorPolicy `yaml:"on_error,omitempty"`
}

Parallel — independent branches at once: in live skills this is gathering evidence from different sources in parallel.

Branches DO NOT SEE each other's variables: otherwise the result would depend on the order of completion, which is non-deterministic — and the format exists for the opposite.

type Profile

type Profile struct {
	Model string `yaml:"model,omitempty"`
	// Sampling is replaced WHOLE, never merged key by key: a half-inherited
	// sampling block turns "why is my top_k from the profile and my temperature
	// my own" into a question asked at every debugging session.
	Sampling *Sampling `yaml:"sampling,omitempty"`
	// Tools — a pointer for the same reason as on a step: `tools: []` in a
	// profile must mean an EMPTY SET, not "unset". The empty set is the guard
	// ("do not go to that source"), and a profile that could not express it
	// would force the guard to be repeated on every step by hand — which is
	// what profiles exist to stop.
	Tools         *[]string   `yaml:"tools,omitempty"`
	MaxCalls      int         `yaml:"max_calls,omitempty"`
	MaxToolErrors int         `yaml:"max_tool_errors,omitempty"`
	OnError       ErrorPolicy `yaml:"on_error,omitempty"`
}

Profile — the generation parameters a step can inherit by name.

Only the model step's envelope: what to generate with and how, plus the radius. No instruction, no save_as, no branching — those are the step's own work, and sharing them would be sharing the step itself (see the CHANGELOG on why named steps were not added).

type Result

type Result struct {
	Text  string
	Calls int
	// CallsFailed — how many of them FAILED. A step with calls=7 and zero useful
	// results is outwardly indistinguishable from one that worked, and the
	// digging has to happen in pod logs.
	CallsFailed int
	// Note — the reason the EXECUTOR knows and the engine cannot derive:
	// "stopped after too many misses" versus "hit the ceiling". Empty — the
	// engine judges for itself. Without it the precise reason drowns in a generic
	// "the step produced no text", and fixing happens blind (we stepped on this
	// ourselves).
	Note string
	// Truncated — the answer is unfinished: upstream cut the generation off at
	// the token limit. Separate from Note because it is a MACHINE signal: it is
	// what a decision to replay the step is based on, while Note is the human
	// reason for degradation in a report.
	Truncated bool
	Err       error
}

Result — a step's outcome, as it lands in the flow's variables.

type Run

type Run struct {
	// Instruction — what to do. Supports {{var}} substitution.
	Instruction string `yaml:"instruction,omitempty"`
	// SaveAs — the variable name for the result ("" = the result is not stored).
	SaveAs string `yaml:"save_as,omitempty"`

	// Tools — the tools of THIS step. An empty one inside a non-empty flow = the
	// step is handed no tools at all (it must answer from what is already
	// gathered). A pointer is what tells "unset" from "empty list".
	Tools *[]string `yaml:"tools,omitempty"`

	// MaxCalls — the ceiling on tool calls in this step. A step's field, not the
	// flow's: in live skills the limits differ — one attempt for a code search,
	// up to eight for walking a tree.
	//
	// 0 is NOT "unlimited": the executor substitutes its own ceiling (8
	// generations for ours). An unlimited step is deliberately absent from the
	// format — a step without a ceiling brings back exactly the problem the
	// format was started for.
	MaxCalls int `yaml:"max_calls,omitempty"`

	// MaxToolErrors — how many of the model's MISSES are forgiven beyond
	// max_calls.
	//
	// A miss is a call rejected by the tool (a required argument forgotten,
	// broken JSON). It costs a generation, so it cannot be free, but neither
	// should it eat the budget of USEFUL work: live failure — a review step
	// burned all 6 calls on misses (6 of 7 rejected by the server) and read not
	// a single diff.
	//
	// 0 is the executor's default (2), not "unlimited": endless attempts bring
	// back exactly the problem the format was started for. Exceeding it stops
	// the step with degraded and a named reason, not silently.
	MaxToolErrors int `yaml:"max_tool_errors,omitempty"`

	// OnError — what to do when the step could not. Default is Abort.
	OnError ErrorPolicy `yaml:"on_error,omitempty"`

	// OnEmpty — what an empty result means (see empty.go). Default is
	// EmptyContinue, which is what the engine did before the field existed.
	OnEmpty EmptyPolicy `yaml:"on_empty,omitempty"`
	// OnEmptyValue — what to store instead, for OnEmpty: use. Supports {{var}}.
	OnEmptyValue string `yaml:"on_empty_value,omitempty"`
	// OnEmptyRetries — how many times OnEmpty: retry runs the step again.
	// Unset = DefaultEmptyRetries.
	OnEmptyRetries int `yaml:"on_empty_retries,omitempty"`

	// OneOf — the allowed values of the step's result. Set → the result is
	// normalised: whichever of the listed values is found in the answer is
	// taken; if none is found, the variable goes empty.
	//
	// Why. A classifier step exists for the sake of branching, while a model
	// answers in prose: asked to "answer in one word: t1 or foreign" it replies
	// "Summary: determined the type by prefix. Result: foreign" — correct in
	// meaning and useless to a switch that compares exactly. Live failure: no
	// branch was chosen and the turn fell through to default.
	//
	// This is NOT a format check asked of the model (a request it follows
	// probabilistically) but normalisation ON THE WAY OUT, in code.
	OneOf []string `yaml:"one_of,omitempty"`

	// Model — the model of THIS step (empty = the executor's default).
	Model string `yaml:"model,omitempty"`
	// Sampling — the step's generation parameters (see the Sampling type).
	Sampling *Sampling `yaml:"sampling,omitempty"`
	// ResponseSchema — the schema of the step's structured answer. The result is
	// stored in the variable as an object, fields reachable as {{var.field}}.
	//
	// Meaningful ONLY where decoding grammar works: on the way to the model the
	// schema is dropped silently, and a structured answer would degenerate into
	// "the model usually answers JSON" — an undetectable hole. So the executor
	// MUST refuse if the path to the step's model does not carry the grammar,
	// rather than quietly continue.
	//
	// For the same reason Model is REQUIRED alongside, and Validate enforces
	// it: leaving the choice to a default means whichever model the executor
	// happens to use decides whether the schema applies at all.
	ResponseSchema map[string]any `yaml:"response_schema,omitempty"`
}

Run — a step executed by the model.

type Runner

type Runner interface {
	Run(ctx context.Context, req StepRequest) (Result, error)
}

Runner executes a single step: gives the model an instruction, allowing exactly the listed tools, and returns the answer's text.

This is the ONLY point of contact with the outside world: everything the package knows about models, tools, permissions and telemetry hides behind this interface.

type RunnerFunc

type RunnerFunc func(ctx context.Context, req StepRequest) (Result, error)

RunnerFunc and ToolCallerFunc — functions as implementations of the interfaces.

An embedder usually has nothing to keep in a struct: executing a step means calling their model, calling a tool means using their transport. Requiring a type declaration for that is requiring ceremony.

func (RunnerFunc) Run

func (f RunnerFunc) Run(ctx context.Context, req StepRequest) (Result, error)

type Sampling

type Sampling struct {
	Temperature       *float32 `yaml:"temperature,omitempty"`
	TopP              *float32 `yaml:"top_p,omitempty"`
	TopK              *int     `yaml:"top_k,omitempty"`
	MinP              *float64 `yaml:"min_p,omitempty"`
	RepetitionPenalty *float64 `yaml:"repetition_penalty,omitempty"`
	// MaxTokens — the output-token ceiling of THIS step. Unset — the global
	// MaxOutputTokens applies.
	//
	// Needed where a step is short by nature while a runaway generation is
	// expensive: two chunks of a review produced 32768 and 22482 tokens instead
	// of the usual ~2300, hitting the global ceiling, and one of them broke its
	// own structured answer in the process. A global knob is no help here — it
	// serves consumers with different envelopes.
	MaxTokens *int `yaml:"max_tokens,omitempty"`
	// Reasoning — reasoning depth (low|medium|high) on models that support it.
	// Set on the STEP that needs it: a project measurement showed that high on
	// the investigator role ate 83% of subagent time.
	Reasoning string `yaml:"reasoning,omitempty"`
}

Sampling — a step's generation parameters.

The unit of tuning is the STEP, not the skill, for the same reason tools and max_calls belong to a step: steps do different work. A classifier needs zero temperature — it picks between two values; wording an answer for a human needs it warmer, or the text comes out wooden. One parameter for the whole skill serves both badly.

type Set

type Set struct {
	Var   string `yaml:"var"`
	Value string `yaml:"value"`
}

Set — assigning a variable. The value supports {{var}} substitution.

type Skill added in v0.1.0

type Skill struct {
	// EngineVersion — the format version this skill was written for, i.e. the
	// minimum engine it requires. Absent = 1.0.0 (see version.go).
	EngineVersion string `yaml:"skill_engine_version,omitempty"`
	// SkillVersion — the version of the skill itself. The engine does not read
	// it; measurements do — comparing "was 10 calls, now 3" without a version
	// compares something unknown with something unknown.
	SkillVersion string `yaml:"skill_version,omitempty"`

	Name        string `yaml:"name"`
	Description string `yaml:"description"`
	// TriggerExamples — live phrasings the skill must fire on. Routing is the
	// embedder's business; the field is part of the file, so it is part of the
	// type.
	TriggerExamples []string `yaml:"trigger_examples,omitempty"`

	// Servers — the MCP servers available to the skill: the CEILING of the
	// radius, which `workflow.tools` and a step's `tools` can only narrow.
	Servers []string `yaml:"servers,omitempty"`
	// BuiltinTools — the embedding application's own tools the skill needs
	// (as opposed to an MCP server's).
	BuiltinTools []string `yaml:"builtin_tools,omitempty"`

	// Role and Kind — names of the application's profiles: the format does not
	// close either list.
	Role string `yaml:"role,omitempty"`
	Kind string `yaml:"kind,omitempty"`

	// Temperature — a whole-skill override. Has no effect on the workflow path,
	// where generation parameters belong to the STEP (`sampling:`).
	Temperature *float64 `yaml:"temperature,omitempty"`
	// Disabled — turn the skill off without deleting the file.
	Disabled bool `yaml:"disabled,omitempty"`

	// Mode — which of the two descriptions runs (see ResolveMode). Empty = the
	// default, "there is a workflow → follow it".
	Mode string `yaml:"mode,omitempty"`
	// Workflow — the turn described in steps.
	Workflow *Flow `yaml:"workflow,omitempty"`
	// Playbook — the turn described as a free-form instruction. A full way to
	// describe a skill, not a draft: such a turn is run by the embedding
	// application, the engine takes no part in it.
	Playbook string `yaml:"playbook,omitempty"`

	// Meta — arbitrary data for the application: a schedule, an owner, tags.
	// The engine neither reads it nor checks its shape.
	Meta map[string]any `yaml:"meta,omitempty"`
}

Skill — a whole skill file: the header plus the description of the turn.

Why the header lives here and not in the embedding application. Every field below is already described by the format's schema (`skill.schema.yaml`), i.e. it belongs to the FORMAT — and yet each embedder used to declare its own struct for it, parse the file itself, and re-derive the same rules about which combinations are legal. Two copies of a contract drift by definition: the engine gains a field, the copy does not, and a skill that declares it loads with the field silently dropped.

The engine reads exactly two of these fields (EngineVersion and the pair Workflow/Playbook chosen by Mode). The rest is here because the FILE has them and something must define their shape — the alternative is every embedder inventing it again.

func ParseSkill added in v0.1.0

func ParseSkill(raw []byte, unmarshal Unmarshal) (Skill, error)

ParseSkill reads a skill file. Structure only: the result is not validated, so that a caller reporting on a file (a linter, an editor) can tell "this is not YAML" from "this is YAML that says something wrong" and point at each differently. For the second question call Validate.

Unknown top-level keys are NOT rejected — a plain unmarshal function cannot be told to reject them, and demanding a particular YAML library would make the engine depend on one. That check belongs to the schema, which every embedder can run in an editor and in CI.

func (*Skill) HasPlaybook added in v0.1.0

func (s *Skill) HasPlaybook() bool

HasPlaybook — the skill describes a turn in prose.

func (*Skill) HasWorkflow added in v0.1.0

func (s *Skill) HasWorkflow() bool

HasWorkflow — the skill describes a turn in steps. A `workflow:` key with no steps under it does not count: it is an empty description, not a description of nothing to do.

func (*Skill) ResolveMode added in v0.1.0

func (s *Skill) ResolveMode() (Mode, error)

ResolveMode decides which of the two descriptions this skill runs. See mode.go for the whole table.

func (*Skill) Validate added in v0.1.0

func (s *Skill) Validate() error

Validate checks a parsed skill against the format: the version, the header's required fields, the mode against the descriptions present, and the workflow itself.

The workflow is validated even when the mode points at the playbook. It sits in the file and one edit away from being run: a half you cannot switch to is exactly the failure `mode` exists to prevent — the author switches, the run falls over, and nothing said so while the file was being saved.

type SkillDelegate

type SkillDelegate interface {
	Delegate(ctx context.Context, skill, task string) (string, error)
}

SkillDelegate executes a delegate step. The third (and last) point where the package touches the outside world — next to Runner and ToolCaller.

type Step

type Step struct {
	Name string `yaml:"name,omitempty"`

	// Run — a step performed by the model: an instruction plus a result stored
	// in a variable.
	Run *Run `yaml:",inline"`
	// Switch — branching on a variable's value.
	Switch *Switch `yaml:"switch,omitempty"`
	// If — branching on a condition.
	If *If `yaml:"if,omitempty"`
	// Set — computing a variable without involving the model.
	Set *Set `yaml:"set,omitempty"`
	// Call — calling a tool without involving the model.
	Call *Call `yaml:"call,omitempty"`
	// Exit — stop the flow and return the turn to its previous path.
	Exit *Exit `yaml:"exit,omitempty"`
	// Delegate — hand the work to another skill (see the Delegate type).
	Delegate *Delegate `yaml:"delegate,omitempty"`
	// Parallel — several independent branches at once (see the Parallel type).
	Parallel *Parallel `yaml:"parallel,omitempty"`
	// ForEach — repeat steps for every item of a collection.
	ForEach *ForEach `yaml:"for_each,omitempty"`

	// OnServer — which server the step runs on. Supports {{var}}: the name is
	// computed at execution time.
	//
	// For call: the address of the call; for a model step: whose tools it is
	// handed. Why: 14 of 28 live skills carry VARIANTS of one server (gitlab
	// dev/prod, five k8s clusters, prometheus dev/prod), and without a runtime
	// choice each needs a switch of 2–5 nearly identical branches — the very
	// duplication that diverges on the first edit.
	//
	// Permissions are not weakened: the SUBSTITUTED name is checked, and it must
	// belong to the flow's set.
	OnServer string `yaml:"on_server,omitempty"`

	// Profile — the name of a set of generation parameters to inherit (see
	// profile.go). Whatever the step spells out itself wins; the rest comes
	// from the profile.
	Profile string `yaml:"profile,omitempty"`

	// When — the step's applicability condition (same syntax as If.Cond). False
	// → the step is skipped and the flow moves on.
	//
	// Why separate from If: it is expressible with branching too, but `when`
	// reads as a PROPERTY of the step and does not grow nesting where there is
	// no branch — only "do it or not". Live class: the task matches the skill
	// only partially, and then the turn "sometimes gives the right answer,
	// sometimes not" with no visible reason. A conditional skip is visible in
	// the events.
	When string `yaml:"when,omitempty"`
}

Step — a single step. Exactly one of Run/Switch/If/Set must be filled in; Validate checks that.

func (*Step) Branches

func (s *Step) Branches() [][]Step

Branches returns the nested step sets: switch/if branches, the for_each body, parallel branches. Needed by anyone walking a description in full — validators and the linter: otherwise each of them re-enumerates the places nesting can occur and forgets whichever was added last (exactly how a switch branch would carry a typo into production).

type StepRequest

type StepRequest struct {
	Name        string
	Instruction string
	// Tools — the exact set of allowed tools. nil = the flow's set.
	Tools    []string
	MaxCalls int
	// MaxToolErrors — how many of the model's misses are forgiven beyond
	// MaxCalls.
	MaxToolErrors int
	// Model / Sampling — what to generate the step with and how. Empty = the
	// executor's defaults.
	Model          string
	Sampling       *Sampling
	ResponseSchema map[string]any
	// OneOf — the allowed values of the result. An executor whose model holds a
	// decoding grammar passes them as an enum: then a value outside the list
	// becomes IMPOSSIBLE rather than corrected after the fact.
	OneOf []string
}

StepRequest — what a step's executor needs.

type StepTrace

type StepTrace struct {
	// StartedAt — when the step began. The caller needs it to place the
	// progress line at its chronological position: the event arrives on
	// COMPLETION but refers to the start of the work.
	StartedAt time.Time
	Name      string
	Kind      string // instruction | call | delegate | parallel | for_each | set | switch | if | exit
	Outcome   string // ok | skipped | denied | error | exit | truncated
	Reason    string
	Calls     int
	// CallsFailed — how many of the step's calls failed (see Result.CallsFailed).
	CallsFailed int
	Duration    time.Duration
}

StepTrace — what happened to a single step.

Collected by the engine and handed to the caller, who turns it into events. The engine itself does not emit telemetry — it is detachable and knows nothing about it.

type Switch

type Switch struct {
	Var     string            `yaml:"var"`
	Cases   map[string][]Step `yaml:"cases"`
	Default []Step            `yaml:"default,omitempty"`
}

Switch — branching on a variable's value.

type ToolCaller

type ToolCaller interface {
	CallTool(ctx context.Context, server, tool string, args map[string]any) (string, error)
}

ToolCaller calls a tool directly. The second (and last) point where the package touches the outside world — next to Runner.

type ToolCallerFunc

type ToolCallerFunc func(ctx context.Context, server, tool string, args map[string]any) (string, error)

func (ToolCallerFunc) CallTool

func (f ToolCallerFunc) CallTool(ctx context.Context, server, tool string, args map[string]any) (string, error)

type ToolRef

type ToolRef struct {
	Server string
	Tool   string
	// Dynamic — the server is computed at execution time ({{var}} in on_server),
	// i.e. it is statically unknown: permissions must be checked against the
	// flow's whole set.
	Dynamic bool
}

ToolRef — a tool declared by a skill.

type Unmarshal

type Unmarshal func(data []byte, v any) error

Unmarshal — parsing YAML into a struct. The engine does NOT pull in a yaml library itself: the skill description is read by the embedding application anyway, it already has a parser, and a second copy here would become its dependency — with its versions and its conflicts. Exactly one method is needed, so this is a function rather than an interface.

`yaml.Unmarshal` from gopkg.in/yaml.v3 fits without a wrapper.

type Vocabulary added in v0.4.0

type Vocabulary struct {
	// DecisionMarkers — what a model in THIS application writes just before
	// naming its choice: "Result:", "Ответ:", "Résultat:", "结论:".
	//
	// Used by `one_of` to lift a decision out of prose. A step told to answer
	// in one word still writes a paragraph and puts the answer at the end, and
	// the marker is what tells the decision from the enumeration that precedes
	// it ("choose between A and B… Result: B").
	//
	// It is one of five ways `one_of` normalises an answer, and a NARROW one:
	// the other four need no words at all — an exact match, a single value
	// mentioned, a value mentioned strictly more often, and otherwise nothing.
	// Markers therefore decide only a TIE: the allowed values appear equally
	// often and the answer is prose. Leaving this empty never yields a WRONG
	// value — only, in that tie, an empty one, which is the format's designed
	// outcome for "the model did not decide".
	//
	// When that happens the step's trace names this field, so a missing
	// declaration is visible rather than inferred from a quiet default.
	DecisionMarkers []string

	// TruncationNotes — how THIS application marks a result it shortened:
	// "truncated:", "обрезано:", "gekürzt:". Written at the start of the note
	// the host appends on the last line, e.g. "…\n[truncated: 42kb]".
	//
	// The engine strips such a note before a value reaches a tool argument, a
	// loop or a condition — those want the payload, not the host's remark about
	// it. Its own handle marker (`[mem:…]`) is always recognised; this is for
	// the wording that belongs to the application.
	//
	// Left empty by a host that does append notes, the note travels on into
	// call arguments. That is why it is a declaration and not a guess: the
	// engine cannot tell a remark from content.
	TruncationNotes []string
}

Vocabulary — the words of the embedding application.

The engine ships NONE of its own, and that is the point. Agents that share this format share nothing else: one answers questions about a kitchen, the next about a car fleet, the one after that in a language neither of the first two used. A word list baked into the engine is one application's habit imposed on every other — and imposed INVISIBLY, because a list nobody passed in looks exactly like a list that matched nothing.

The rule the engine keeps for itself is narrow: it knows only the words it WRITES. A failure it recorded (`ERROR:`, `DENIED:`) it can read back, and the working-memory handle it defines (`[mem:id]`) it can recognise. Everything beyond that is here, supplied by whoever embeds it.

An empty field is not a mistake. It means "my application has no such words", and the mechanism that needed them steps aside — visibly, never by guessing.

Directories

Path Synopsis
Package lint checks a skill against the rules of the format — the ones the engine cannot enforce at execution time without having already burned the turn.
Package lint checks a skill against the rules of the format — the ones the engine cannot enforce at execution time without having already burned the turn.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL