141 lines
6.4 KiB
Markdown
141 lines
6.4 KiB
Markdown
# SKILL.md — the contract for new skills
|
|
|
|
This file is the authoritative spec for what a new skill is and how its code
|
|
body must be written. It is fed verbatim to the code-generation model so every
|
|
generated skill is consistent, and it is read by humans who want to know what a
|
|
good skill looks like.
|
|
|
|
## What a skill is
|
|
|
|
A skill is a **leaf** in the agent's skill tree, reached by a chain of SemIf
|
|
decisions (category -> skill). It is one specific, single-purpose action the
|
|
agent can take — never a broad bucket (that is a category's job). It runs the
|
|
standard skill loop: observe -> predict -> act -> observe -> assess.
|
|
|
|
A skill is two things:
|
|
|
|
1. **A manifest** — the registry entry that makes it navigable and describes
|
|
what it does.
|
|
2. **A code body** — a runnable Python module implementing the `predict` and
|
|
`act` phases.
|
|
|
|
## Manifest schema
|
|
|
|
Registered in `data/categories.json` (and mirrored in the running tree). Fields:
|
|
|
|
| Field | Meaning |
|
|
| -------------------- | -------------------------------------------------------------------- |
|
|
| `name` | `category.skill` dotted snake_case id. Lowercase letters, digits, `_` and `.` only (`[a-z0-9_]+(?:\.[a-z0-9_]+)*`). Single purpose. |
|
|
| `category` | The top-level category bucket the skill lives under. |
|
|
| `description` | One to two sentences: what the skill does, for navigation. |
|
|
| `allowed_inputs` | What the skill accepts / needs as input context. |
|
|
| `actions` | The concrete actions the skill takes. |
|
|
| `cost_budget` | Relative budget for one run; a run that exceeds it fails fast. |
|
|
| `decision_log_ref` | Reference to the decision rows this skill logged during a run. |
|
|
|
|
Only `name` and `description` are required for a stub; the rest fill in as the
|
|
skill is exercised.
|
|
|
|
## Code body contract
|
|
|
|
The generated module is persisted to `data/skills/<category>/<name>.py` and
|
|
imported at runtime. It must satisfy **all** of the following:
|
|
|
|
### Required functions
|
|
|
|
```python
|
|
def predict(ctx, request) -> Prediction:
|
|
"""Forecast + make any SemIf sub-decisions. Return the prediction."""
|
|
|
|
def act(ctx, request, prediction) -> ActionResult:
|
|
"""Execute the action. Return the result + new state."""
|
|
```
|
|
|
|
- `ctx` is an `ActionContext` with `ctx.engine` (the real SemIf engine) and
|
|
`ctx.config` (the agent config dict).
|
|
- `request` is the `Request` being handled.
|
|
- `Prediction(text: str, decisions: list)` and
|
|
`ActionResult(action_log: str, new_state: str, needs_input: str | None = None)`
|
|
are imported from
|
|
`semif_agent.skills`; return those exact types. `decisions` carries any
|
|
`(DecisionRequest, DecisionResult)` pairs made during predict so they are
|
|
logged as training rows. `needs_input` carries a question for the human; see
|
|
the rules below.
|
|
|
|
### Rules (hard requirements)
|
|
|
|
- **Stdlib only.** No third-party imports, no files outside the project. The
|
|
core agent is pure-stdlib and runs on the thin dev box.
|
|
- **No mocking.** Sub-decisions use the real engine: build a
|
|
`DecisionRequest(state, question, options=[Option(id, description), ...])`
|
|
and call `ctx.engine.call(decision)`; return it inside `Prediction.decisions`.
|
|
- **Never swallow the request.** If the skill cannot act, return an
|
|
`ActionResult` with a short `action_log` explaining why and set `new_state`
|
|
back to `request.text`.
|
|
- **Request input when data is missing.** If a required piece of data is not
|
|
in the request or in local files, do not fail silently: return an
|
|
`ActionResult(action_log="...", new_state=request.text, needs_input="<question>")`.
|
|
The run pauses and the human is asked. The answer arrives on
|
|
`request.user_input` and `act` is called again with the *same* prediction —
|
|
check `request.user_input` on the resume pass to finish the run (or ask again
|
|
if it is still insufficient).
|
|
- **Write files under configured data dirs only** (e.g. `ctx.config["drafts"]`),
|
|
never anywhere else on disk.
|
|
- **Fail fast on budget.** Keep the work small; do not loop or retry in code.
|
|
- **Names match the manifest.** The module is imported as its manifest name;
|
|
the functions are `predict` and `act` exactly.
|
|
|
|
## Conventions
|
|
|
|
- Single purpose, single file, single module.
|
|
- Avoid duplicating an existing skill in the same category.
|
|
- `predict` resolves ambiguity (arguments, recipients, targets) with SemIf
|
|
sub-decisions, mirroring how `email.compose` resolves its recipient.
|
|
- `act` performs the concrete action and writes a human-readable `action_log`
|
|
that the self-assessment LLM can judge.
|
|
|
|
## Acceptance criteria
|
|
|
|
A generated skill is accepted only if:
|
|
|
|
1. It compiles (`compile(..., "exec")` succeeds) and defines both `predict`
|
|
and `act`.
|
|
2. Its `name` matches the manifest regex and its `category` is given.
|
|
3. Its body imports nothing outside the stdlib and the agent package.
|
|
4. It uses `ctx.engine` (never mocks) and returns proper `Prediction` /
|
|
`ActionResult` types.
|
|
5. It is single-purpose and does not duplicate an existing category leaf.
|
|
|
|
## Worked example
|
|
|
|
`email.compose` resolves its recipient with a SemIf sub-decision, then writes a
|
|
draft file:
|
|
|
|
```python
|
|
def predict(ctx, request):
|
|
contacts = read_contacts(ctx.config) # config-driven, local data
|
|
decision = DecisionRequest(
|
|
state=f"{request.text} [current process: none]",
|
|
question="Which contact is the intended recipient?",
|
|
options=[Option(c["name"], c.get("description", "")) for c in contacts]
|
|
+ [Option("none", "None of the listed contacts.")],
|
|
)
|
|
result = ctx.engine.call(decision)
|
|
return Prediction(text=f"recipient is {result.selected}",
|
|
decisions=[(decision, result)])
|
|
|
|
def act(ctx, request, prediction):
|
|
recipient = prediction.text.removeprefix("recipient is ")
|
|
drafts = Path(ctx.config.get("drafts", "data/drafts"))
|
|
drafts.mkdir(parents=True, exist_ok=True)
|
|
target = drafts / f"{request.id}.txt"
|
|
target.write_text(f"To: {recipient}\nBody: {request.text}\n")
|
|
return ActionResult(
|
|
action_log=f"email.compose: wrote draft {target} for {recipient!r}.",
|
|
new_state=f"Draft written to {target.name} for {recipient}.",
|
|
)
|
|
```
|
|
|
|
Write skill bodies in this shape: resolve ambiguity in `predict` via
|
|
`ctx.engine`, do the work in `act`, keep both stdlib-only, and return the
|
|
proper types. |