feat(skills): agentic loop wiring for SKILL.md bodies (#781)#807
Merged
senamakel merged 3 commits intotinyhumansai:mainfrom Apr 23, 2026
Merged
Conversation
…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>
Contributor
📝 WalkthroughWalkthroughThe pull request introduces a skill injection system that enriches agent turn processing. It adds a new Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::turnhad no way to expose a matched skill's body to the model.Three micro-commits:
feat(skills/core): Skill::read_body for inference-time body fetch— adds a thinSkill::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 soskills.listremains cheap.feat(skills/core): inject module — matcher + renderer + size cap— introducesopenhuman::skills::injectwith purematch_skills+render_injectionfunctions plus 24 unit tests.feat(agent): wire SKILL.md body injection into Agent::turn— calls the matcher+renderer insideAgent::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 fromsrc-tauri/src/commands/chat.rsinto the core crate'ssrc/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:
@<skill-name>mention — always force-injects. Case-insensitive; dashes/underscores normalised so@Pdf-Crunchmatchespdf_crunch. Mentions preceded by an alphanumeric or.are ignored so email addresses likefoo@alice.comdo not false-trigger.user-invocable: falsein its frontmatter:descriptionappears as a case-insensitive substring of the message, ORtagappears as a whole-word substring, ORnameitself appears as a whole word (skipped for ≤2-char names).user-invocable: falseskills 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@mentioncannot 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 hardDEFAULT_MAX_INJECTION_BYTES = 8192cap (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 withSkipReason::BudgetExhaustedand logged.Every candidate emits a grep-friendly
[skills:inject]tracing line withmatched, reason, andinjected_bytes.Integration in
Agent::turnRight after the memory-context block is prepended,
match_skills+render_injectionrun against the current user message, usingSkill::read_bodyas the resolver. The rendered block is prepended ahead of the memory block, giving:A single
[skills:inject] summary candidates=… matched=… injected_bytes=… truncated_any=…log line closes out each turn.Design tradeoffs
@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_bodyhits 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 inSkillat discover time) was rejected because it doubles catalog memory for a cold path.Submission Checklist
cargo testcases inskills::inject: substring/tag/name match paths, email-@ false-negative guard, case/separator normalisation,user-invocableopt-out (spec + deprecated alias), mention-order preservation, longer-description ranking,@-over-auto precedence, size-cap truncation marker, budget-exhausted skip, body-unavailable skip, legacy-skillread_bodyfallback, and a tempfile round-trip through the realSkill::read_bodyparser.Agent::turnintegration path is exercised by existing agent harness tests once skills exist on disk.inject.rsexplains the heuristic, ordering, size cap, and logging contract.///on every new public function + struct.Skill::read_bodydocuments its legacy fallback.@-as-word-boundary rationale, mention-index sentinel for auto-matches, UTF-8 truncation alignment).Impact
openhuman_corelib,openhuman-corebin). Every agent turn now runs the matcher. On a catalog of 50 skills with a 100-char message,match_skillsis sub-microsecond (pure string ops on owned data). Only matched skills pay a file read. No new dependencies.read_bodyreads files by thelocationalready persisted onSkill; the discover path is responsible for the traversal/symlink guards. No new filesystem surface.SkillSummaryunchanged; RPCs unchanged. No user-facing UI change in this PR.Related
Agentic loop wiring for SKILL.md bodies). Other feat(skills): complete #681 acceptance — package install, SKILL.md inference wiring, UI polish, docs #781 items (install_from_package, uninstall/enable RPCs, Moltbook/Solana E2E, UI redesign, docs) stay in that issue.Agent::turnend-to-end against a disk-backed skill fixture@skill-nameautocomplete in the composerSummary by CodeRabbit