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
- Variables
- func AssetRefsInArgs(args map[string]any) []string
- func AssetRefsInText(text string) []string
- func CheckEngineVersion(declared string) error
- func CompareSkillVersions(a, b string) (int, error)
- func CondVar(cond string) (string, bool)
- func Migrate(raw []byte) ([]byte, bool, error)
- func SchemaSummary(unmarshal Unmarshal) string
- func SchemaSummaryRU(unmarshal Unmarshal) string
- func SplitToolRef(ref string) (server, tool string, ok bool)
- func ValidateSkillName(name string) error
- type Asset
- type AssetResolver
- type Call
- type Delegate
- type Deps
- type EmptyPolicy
- type ErrorPolicy
- type Exit
- type ExitError
- type Fetch
- type Flow
- type ForEach
- type If
- type MemoryReader
- type Mode
- type Outcome
- type Parallel
- type Profile
- type Result
- type Run
- type Runner
- type RunnerFunc
- type Sampling
- type Set
- type Skill
- type SkillDelegate
- type Step
- type StepRequest
- type StepTrace
- type Switch
- type ToolCaller
- type ToolCallerFunc
- type ToolRef
- type Unmarshal
Constants ¶
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".
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.
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.
const DefaultMaxIterations = 10
DefaultMaxIterations — the loop ceiling when the skill did not set its own.
const EngineVersion = "2.1.0"
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.
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.
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.
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`.
const SkippedSuffix = ".skipped"
SkippedSuffix — the suffix of the variable holding the list of skipped branches: `collect: findings` puts them into `findings.skipped`.
Variables ¶
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.
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".
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.
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 ¶
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 ¶
AssetRefsInText lists assets substituted into text ({{asset:name}}).
func CheckEngineVersion ¶
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 ¶
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 CondVar ¶ added in v0.1.0
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 Migrate ¶
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 ¶
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 ¶
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 ¶
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
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 ¶
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
// 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.
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"`
// 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 wiki 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 ¶
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.
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 ¶
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.
func ResolveMode ¶
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 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
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
HasPlaybook — the skill describes a turn in prose.
func (*Skill) HasWorkflow ¶ added in v0.1.0
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
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
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 ¶
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 ¶
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 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 ¶
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.
Source Files
¶
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. |