Skip to content

feat(skills): agentic loop wiring for SKILL.md bodies (#781)#807

Merged
senamakel merged 3 commits intotinyhumansai:mainfrom
oxoxDev:feat/781-chat-skill-md-inference
Apr 23, 2026
Merged

feat(skills): agentic loop wiring for SKILL.md bodies (#781)#807
senamakel merged 3 commits intotinyhumansai:mainfrom
oxoxDev:feat/781-chat-skill-md-inference

Conversation

@oxoxDev
Copy link
Copy Markdown
Contributor

@oxoxDev oxoxDev commented Apr 22, 2026

Summary

Wire installed SKILL.md skills into the agent's inference loop so their instruction bodies reach the LLM at the top of each matching user turn. Closes the runtime half of the first checkbox in the #781 umbrella — the browse/create/install UI in #740 surfaces skills to the user, but until now Agent::turn had no way to expose a matched skill's body to the model.

Three micro-commits:

  1. feat(skills/core): Skill::read_body for inference-time body fetch — adds a thin Skill::read_body(&self) -> Option<String> that re-parses the on-disk SKILL.md and returns everything after the frontmatter terminator. Stays off the hot catalog-construction path so skills.list remains cheap.
  2. feat(skills/core): inject module — matcher + renderer + size cap — introduces openhuman::skills::inject with pure match_skills + render_injection functions plus 24 unit tests.
  3. feat(agent): wire SKILL.md body injection into Agent::turn — calls the matcher+renderer inside Agent::turn, prepending matched skill blocks ahead of the existing [MEMORY_CONTEXT] layer.

Problem

engine.all_tools() discovers tools from QuickJS-based skills, but SKILL.md skills are instruction playbooks, not JS handlers. Nothing in the agentic loop read their bodies — the LLM saw a catalog of names and short descriptions via the prompt builder, but never the actual instruction text. Users installing a SKILL.md via PR #740 got a browsable entry that silently did nothing at chat time.

The umbrella issue (#781) frames this as a matcher + injector step in chat_send_inner. In this repo the inference logic has moved from src-tauri/src/commands/chat.rs into the core crate's src/openhuman/agent/harness/session/turn.rs (Agent::turn) — that is the live path this change hooks into.

Solution

Matcher (inject::match_skills)

For each installed skill:

  1. Explicit @<skill-name> mention — always force-injects. Case-insensitive; dashes/underscores normalised so @Pdf-Crunch matches pdf_crunch. Mentions preceded by an alphanumeric or . are ignored so email addresses like foo@alice.com do not false-trigger.
  2. Otherwise, when the skill does not declare user-invocable: false in its frontmatter:
    • description appears as a case-insensitive substring of the message, OR
    • any tag appears as a whole-word substring, OR
    • the skill name itself appears as a whole word (skipped for ≤2-char names).
  3. user-invocable: false skills inject only on explicit @ mention.

@ counts as a word-boundary character for whole-word matches, so a name/tag that lands inside an email or another @mention cannot slip through.

Output order is stable: @ mentions first in message-byte order, then auto-matches by description length descending, ties broken alphabetically by skill name.

Renderer (inject::render_injection)

Builds [SKILL:<name>]\n<body>\n[/SKILL] blocks within a hard DEFAULT_MAX_INJECTION_BYTES = 8192 cap (the #781 acceptance limit). When a body would exceed the remaining budget the body is truncated on a UTF-8 boundary and the close tag becomes [/SKILL:truncated] so the LLM knows content was cut short. Later candidates that no longer fit are skipped with SkipReason::BudgetExhausted and logged.

Every candidate emits a grep-friendly [skills:inject] tracing line with matched, reason, and injected_bytes.

Integration in Agent::turn

Right after the memory-context block is prepended, match_skills + render_injection run against the current user message, using Skill::read_body as the resolver. The rendered block is prepended ahead of the memory block, giving:

[SKILL:<name>]
<body>
[/SKILL]

[MEMORY_CONTEXT]
<memory>
[/MEMORY_CONTEXT]

<user message>

A single [skills:inject] summary candidates=… matched=… injected_bytes=… truncated_any=… log line closes out each turn.

Design tradeoffs

  • Substring + whole-word matching is deliberately coarse. The 8 KiB cap bounds the blast radius of false positives, and @ mentions give users a deterministic escape hatch. Fancier ranking (embeddings, LLM rerank) can drop in behind the same function signature without touching the caller.
  • read_body hits the filesystem per matched skill per turn. SKILL.md files are tiny (<8 KiB) and the matcher trims to a short list, so the cost is negligible next to the LLM call; explicit alternative (caching bodies in Skill at discover time) was rejected because it doubles catalog memory for a cold path.
  • Injection lives in the user message, not the system prompt, so it does not invalidate the system-prompt KV cache prefix.

Submission Checklist

  • Unit tests — 24 new cargo test cases in skills::inject: substring/tag/name match paths, email-@ false-negative guard, case/separator normalisation, user-invocable opt-out (spec + deprecated alias), mention-order preservation, longer-description ranking, @-over-auto precedence, size-cap truncation marker, budget-exhausted skip, body-unavailable skip, legacy-skill read_body fallback, and a tempfile round-trip through the real Skill::read_body parser.
  • E2E / integration — Not added in this PR; relying on the unit coverage while the matcher stabilises. The Agent::turn integration path is exercised by existing agent harness tests once skills exist on disk.
  • N/A
  • Doc comments — Module-level rustdoc on inject.rs explains the heuristic, ordering, size cap, and logging contract. /// on every new public function + struct. Skill::read_body documents its legacy fallback.
  • Inline comments — Added where logic is non-obvious (@-as-word-boundary rationale, mention-index sentinel for auto-matches, UTF-8 truncation alignment).

Impact

  • Runtime: Rust core (openhuman_core lib, openhuman-core bin). Every agent turn now runs the matcher. On a catalog of 50 skills with a 100-char message, match_skills is sub-microsecond (pure string ops on owned data). Only matched skills pay a file read. No new dependencies.
  • Security: read_body reads files by the location already persisted on Skill; the discover path is responsible for the traversal/symlink guards. No new filesystem surface.
  • Performance: KV-cache stable because injection lives in the user message. 8 KiB cap is a hard upper bound on prompt growth per turn.
  • Migration / compatibility: Additive. Wire format for SkillSummary unchanged; RPCs unchanged. No user-facing UI change in this PR.

Related

Summary by CodeRabbit

  • New Features
    • Agent now automatically matches and injects relevant skills into conversation context based on user input
    • Skills are intelligently selected through explicit mentions, name matching, and description/tag analysis
    • Injected skills are size-capped with proper truncation handling to maintain performance

oxoxDev and others added 3 commits April 23, 2026 00:10
…nyhumansai#781)

`SkillSummary` / `Skill` held frontmatter + resource paths but never
surfaced the SKILL.md body — the actual instruction block the LLM needs
at inference time. Add `Skill::read_body(&self) -> Option<String>` which
re-parses the on-disk SKILL.md and returns everything after the
frontmatter terminator. Returns `None` for legacy `skill.json` skills,
missing-location skills, or unparseable files.

Kept off the main catalog-construction path deliberately — `skills.list`
must stay cheap. Only the subset of skills that match a user turn pay the
disk-read cost, from [`openhuman::skills::inject`] (introduced in a
subsequent commit).

Refs tinyhumansai#781.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…yhumansai#781)

New `openhuman::skills::inject` module expose two pure functions:

* `match_skills(&[Skill], &str) -> Vec<SkillMatch>` — scans a user
  message against the installed SKILL.md catalog. Heuristics, in order
  of precedence:

  1. Explicit `@<skill-name>` mentions (case-insensitive, dashes /
     underscores normalised; not triggered when preceded by an
     alphanumeric or `.` so email addresses like `foo@alice.com` do not
     false-match). Always win, bypass any opt-out flag.
  2. Auto-match via description-substring, whole-word tag match, or
     whole-word name match — only when the skill does NOT declare
     `user-invocable: false` in its frontmatter (default opt-in).
  3. `user-invocable: false` skills are skipped unless `@`-mentioned.

  `@` is treated as a word-boundary character for whole-word matches so
  a skill named `alice` cannot match inside `foo@alice.example.com`.

  Output is stably ordered: `@` mentions first (by message-byte index),
  then auto-matches by description length descending, then skill name
  alphabetical. Documented tiebreaker.

* `render_injection(&[SkillMatch], max_bytes, body_resolver) ->
  Injection` — builds `[SKILL:<name>]\n<body>\n[/SKILL]` blocks within
  a hard byte cap (default `DEFAULT_MAX_INJECTION_BYTES = 8192`, the
  tinyhumansai#781 acceptance limit). When a body would exceed the remaining budget,
  it is truncated on a UTF-8 boundary and the close tag becomes
  `[/SKILL:truncated]` so the LLM knows the content was cut short. Later
  candidates that no longer fit are skipped with
  `SkipReason::BudgetExhausted` and logged.

  Every candidate emits a grep-friendly `[skills:inject]` tracing line
  with matched/reason/injected_bytes; a summary line lives in the caller.

24 unit tests cover: substring / tag / name match paths, email-@
false-negative guard, case + separator normalisation, user-invocable
opt-out (spec + deprecated alias), mention-order preservation, longer-
description ranking, `@`-mention precedence over auto-matches, size-cap
truncation marker, budget-exhausted skip path, body-unavailable skip,
legacy-skill read_body fallback, and a tempfile round-trip that parses
a real SKILL.md through `Skill::read_body`.

Refs tinyhumansai#781.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…sai#781)

Immediately after the memory-context block is prepended to the enriched
user message in `Agent::turn`, call `skills::inject::match_skills` +
`render_injection` with `Skill::read_body` as the resolver and prepend
the rendered `[SKILL:<name>] ... [/SKILL]` blocks ahead of the memory
block. Final user-turn layout becomes:

  [SKILL:...] ... [/SKILL]
  [MEMORY_CONTEXT]
  ...
  [/MEMORY_CONTEXT]

  <user message>

Emit a `[skills:inject] summary` log line per turn with
candidates/matched/injected_bytes/truncated_any; per-candidate decisions
are emitted from inside `render_injection` with the same prefix. When no
skills match, log a single debug line and skip the rendering call so a
zero-match turn stays cheap.

Closes the runtime half of the tinyhumansai#781 "Agentic loop wiring for SKILL.md
bodies" checkbox. The matcher + renderer are pure functions; this commit
is the only place that reads skill bodies from disk.

Refs tinyhumansai#781.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

📝 Walkthrough

Walkthrough

The pull request introduces a skill injection system that enriches agent turn processing. It adds a new inject module to match installed SKILL.md files against user messages and render matched skills' instruction bodies with size constraints. The agent's turn handler integrates this enrichment after memory context processing.

Changes

Cohort / File(s) Summary
Skill Injection Core
src/openhuman/skills/inject.rs
New module implementing skill matching against user messages via explicit mentions (@<skill-name>), normalized names, and auto-match heuristics (description substrings, tag matching). Renders matched skills' instruction bodies into [SKILL:…] blocks with UTF-8-safe truncation and configurable 8 KiB budget enforcement. Includes structured decision tracking and comprehensive unit tests.
Skill Infrastructure
src/openhuman/skills/ops.rs, src/openhuman/skills/mod.rs
Adds Skill::read_body() method to re-read markdown instruction body from SKILL.md files at runtime, with warning logging on parse failures. Exports new inject submodule.
Agent Turn Integration
src/openhuman/agent/harness/session/turn.rs
Integrates skill injection into Agent::turn after memory-context enrichment. Matches installed skills against user message, renders matched skills' bodies with size-capped injection, and conditionally prepends the injection to enriched turn input with debug/info logging.

Sequence Diagram

sequenceDiagram
    participant User
    participant AgentTurn as Agent::turn
    participant MemoryEnrichment as Memory<br/>Enrichment
    participant SkillMatch as Skill<br/>Matching
    participant SkillRender as Skill<br/>Rendering
    participant Output as Enriched<br/>User Input

    User->>AgentTurn: user message
    AgentTurn->>MemoryEnrichment: extract context
    MemoryEnrichment-->>AgentTurn: enriched content
    AgentTurn->>SkillMatch: match skills against message
    SkillMatch-->>AgentTurn: matched skills (ordered)
    AgentTurn->>SkillRender: render matched bodies<br/>(max 8 KiB)
    SkillRender-->>AgentTurn: skill blocks + truncation status
    AgentTurn->>Output: prepend injection<br/>+ enriched content
    Output-->>User: final enriched input
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A hop and a match, skills align!
The agent now gathers this wisdom so fine,
With size-capped injection and truncation so neat,
Our mention-aware matching makes features complete!
Enrichment layered, the turn now takes flight! 🚀

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: wiring SKILL.md instruction bodies into the agent inference loop for skill matching and injection.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@oxoxDev oxoxDev marked this pull request as ready for review April 23, 2026 11:29
@oxoxDev oxoxDev requested review from a team and graycyrus April 23, 2026 11:29
@senamakel senamakel merged commit 9d7237b into tinyhumansai:main Apr 23, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(skills): complete #681 acceptance — package install, SKILL.md inference wiring, UI polish, docs

2 participants