diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 5c9482bda..1b092ec41 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -159,33 +159,6 @@ class ContextBuilder: return "\n\n---\n\n".join(parts) - def build_runtime_context_blocks( - self, - current_message: str, - blocks: Sequence[RuntimeContextBlock] | None = None, - ) -> list[RuntimeContextBlock]: - """Add explicitly invoked skill instructions to this turn's runtime context.""" - merged = list(blocks or ()) - invoked = self.skills.get_explicitly_invoked_skills(current_message) - if not invoked: - return merged - always_active = set(self.skills.get_always_skills()) - skill_names = [name for name in invoked if name not in always_active] - skill_content = self.skills.load_skills_for_context(skill_names) - if not skill_content: - return merged - skill_block = RuntimeContextBlock( - source="explicit_skills", - content=( - "[Active Skills — instructions for this user turn]\n" - f"{skill_content}\n" - "[/Active Skills]" - ), - ) - if skill_block not in merged: - merged.append(skill_block) - return merged - @staticmethod def _without_duplicate_session_summary( entries: list[dict[str, Any]], @@ -347,11 +320,12 @@ class ContextBuilder: ) -> dict[str, Any]: """Build only the fresh turn message without merging it into history.""" content = self.build_user_content(current_message, image_paths=media) - blocks = ( - self.build_runtime_context_blocks(current_message, runtime_context_blocks) - if current_role == "user" - else [] - ) + blocks: list[RuntimeContextBlock] = [] + if current_role == "user": + blocks.extend(runtime_context_blocks or ()) + skill_context = self.skills.build_explicit_skill_runtime_context(current_message) + if skill_context is not None and skill_context not in blocks: + blocks.append(skill_context) merged, runtime_context_meta = append_runtime_context(content, blocks) current: dict[str, Any] = {"role": current_role, "content": merged} if current_role == "user" and runtime_context_meta is not None: diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 945a2b9c4..8d298e2e0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -797,10 +797,12 @@ class AgentLoop: ] blocks = runtime_context_blocks_from_metadata(request.metadata) blocks.extend(await resolve_runtime_context(providers, request)) - return self.context.build_runtime_context_blocks( - request.original_user_text or "", - blocks, + skill_context = self.context.skills.build_explicit_skill_runtime_context( + request.original_user_text or "" ) + if skill_context is not None and skill_context not in blocks: + blocks.append(skill_context) + return blocks async def _dispatch_command_inline( self, diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index f1a2e513f..b7ec66afd 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -9,6 +9,8 @@ from typing import Any, cast import yaml +from nanobot.runtime_context import RuntimeContextBlock + # Default builtin skills directory (relative to this file) BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" @@ -177,6 +179,28 @@ class SkillsLoader: invoked.append(name) return invoked + def build_explicit_skill_runtime_context( + self, + text: str, + ) -> RuntimeContextBlock | None: + """Load non-always skills explicitly invoked by the current message.""" + skill_names = self.get_explicitly_invoked_skills(text) + if not skill_names: + return None + always_active = set(self.get_always_skills()) + skill_names = [name for name in skill_names if name not in always_active] + content = self.load_skills_for_context(skill_names) + if not content: + return None + return RuntimeContextBlock( + source="explicit_skills", + content=( + "[Active Skills — instructions for this user turn]\n" + f"{content}\n" + "[/Active Skills]" + ), + ) + def build_skills_summary(self, exclude: set[str] | None = None) -> str: """ Build a summary of all skills (name, description, path, availability). diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index 2c1749eba..fd8d0b290 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -383,6 +383,36 @@ def test_explicit_skill_references_resolve_available_enabled_names_in_order( assert invoked == ["alpha"] +def test_multiple_explicit_skills_share_one_ordered_runtime_context(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + skills_root = workspace / "skills" + skills_root.mkdir(parents=True) + _write_skill(skills_root, "alpha", body="Alpha instructions") + _write_skill(skills_root, "beta", body="Beta instructions") + _write_skill( + skills_root, + "always", + metadata_json={"always": True}, + body="Always instructions", + ) + builtin = tmp_path / "builtin" + builtin.mkdir() + loader = SkillsLoader(workspace, builtin_skills_dir=builtin) + + context = loader.build_explicit_skill_runtime_context( + "Use $beta, then $alpha, $beta again, and $always." + ) + + assert context is not None + assert context.source == "explicit_skills" + assert context.content.count("### Skill: beta") == 1 + assert context.content.count("### Skill: alpha") == 1 + assert context.content.index("### Skill: beta") < context.content.index( + "### Skill: alpha" + ) + assert "### Skill: always" not in context.content + + # -- multiline description tests (YAML folded > and literal |) -----------------