From a0abe9df3bfe5b6a5be5f576f769defa4a5046fd Mon Sep 17 00:00:00 2001 From: hangweizhang Date: Sun, 7 Jun 2026 10:44:32 +0800 Subject: [PATCH] fix(workspace): inject subagents and skills summary into workspace context The WorkspaceContextMiddleware previously only injected AGENTS.md, MEMORY.md, and knowledge/ content into the system prompt. Subagent declarations and skill directories were not included in the injected workspace context, causing the LLM to be unaware of available subagents and skills defined in the workspace. This change adds: - SUBAGENTS_DIR constant to WorkspaceConstants - getSubagentsDir(), listSubagentFiles(), listSkillDirs() methods to WorkspaceManager - buildSubagentsBlock() and buildSkillsBlock() in WorkspaceContextMiddleware to inject subagent and skill summaries (name + description from YAML frontmatter) into - extractDescriptionFromFrontmatter() utility for parsing YAML frontmatter description fields - Unit tests for extractDescriptionFromFrontmatter() Closes #1650 --- .../WorkspaceContextMiddleware.java | 112 +++++++++++++++++- .../agent/workspace/WorkspaceConstants.java | 1 + .../agent/workspace/WorkspaceManager.java | 97 +++++++++++++++ .../WorkspaceContextMiddlewareTest.java | 92 ++++++++++++++ 4 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddlewareTest.java diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddleware.java index 25d0759f1a..cf606a2ffd 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddleware.java @@ -140,12 +140,16 @@ private String buildWorkspaceSection(RuntimeContext rc) { String sessionContext = buildSessionContextSection(workspace, rc); String knowledgeBlock = buildKnowledgeBlock(rc, knowledgeContent, workspace); + String subagentsBlock = buildSubagentsBlock(rc, workspace); + String skillsBlock = buildSkillsBlock(rc, workspace); String additionalBlock = buildAdditionalContextBlock(rc); int fixedTokens = estimateTokens(sessionContext) + estimateTokens(agentsContent) + estimateTokens(knowledgeBlock) + + estimateTokens(subagentsBlock) + + estimateTokens(skillsBlock) + estimateTokens(additionalBlock); int memoryTokens = estimateTokens(memoryContent); int available = maxContextTokens - fixedTokens; @@ -157,7 +161,13 @@ private String buildWorkspaceSection(RuntimeContext rc) { buildWorkspaceParagraph(workspace, workspaceManager.getFilesystem()); String loadedContext = buildLoadedContextSection( - agentsContent, memoryContent, knowledgeBlock, additionalBlock, rc); + agentsContent, + memoryContent, + knowledgeBlock, + subagentsBlock, + skillsBlock, + additionalBlock, + rc); return assembleSection( sessionContext, GUIDANCE_TEMPLATE, workspaceParagraph, loadedContext); } @@ -338,6 +348,8 @@ private String buildLoadedContextSection( String agentsContent, String memoryContent, String knowledgeBlock, + String subagentsBlock, + String skillsBlock, String additionalBlock, RuntimeContext rc) { StringBuilder sb = new StringBuilder(); @@ -345,6 +357,12 @@ private String buildLoadedContextSection( sb.append(buildXmlContext("agents_context", agentsContent)); sb.append(buildXmlContext("memory_context", memoryContent)); sb.append(buildXmlContext("domain_knowledge_context", knowledgeBlock)); + if (!subagentsBlock.isBlank()) { + sb.append(" ").append(subagentsBlock).append("\n"); + } + if (!skillsBlock.isBlank()) { + sb.append(" ").append(skillsBlock).append("\n"); + } if (!additionalBlock.isBlank()) { sb.append(additionalBlock); } @@ -414,4 +432,96 @@ private String buildKnowledgeBlock(RuntimeContext rc, String knowledgeContent, P return sb.toString(); } + + /** + * Builds a summary of subagent declarations found in the workspace {@code subagents/} directory. + * Lists available subagent names and their description from YAML frontmatter. + */ + private String buildSubagentsBlock(RuntimeContext rc, Path workspace) { + List subagentFiles = workspaceManager.listSubagentFiles(rc); + if (subagentFiles.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("Available subagents (declare via agent_spawn tool):\n"); + for (Path subagentFile : subagentFiles) { + String fileName = subagentFile.getFileName().toString(); + String agentId = + fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + String content = + workspaceManager.readManagedWorkspaceFileUtf8( + rc, workspace.relativize(subagentFile).toString().replace('\\', '/')); + String description = extractDescriptionFromFrontmatter(content); + sb.append("- ").append(agentId); + if (description != null && !description.isBlank()) { + sb.append(": ").append(description.strip()); + } + sb.append("\n"); + } + sb.append(""); + return sb.toString(); + } + + /** + * Builds a summary of skill directories found in the workspace {@code skills/} directory. + * Lists available skill names and their description from SKILL.md frontmatter. + */ + private String buildSkillsBlock(RuntimeContext rc, Path workspace) { + List skillDirs = workspaceManager.listSkillDirs(rc); + if (skillDirs.isEmpty()) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append("Available skills (load via load_skill_through_path tool):\n"); + for (Path skillDir : skillDirs) { + String skillName = skillDir.getFileName().toString(); + String skillMdRel = + workspace.relativize(skillDir).toString().replace('\\', '/') + "/SKILL.md"; + String content = workspaceManager.readManagedWorkspaceFileUtf8(rc, skillMdRel); + String description = extractDescriptionFromFrontmatter(content); + sb.append("- ").append(skillName); + if (description != null && !description.isBlank()) { + sb.append(": ").append(description.strip()); + } + sb.append("\n"); + } + sb.append(""); + return sb.toString(); + } + + /** + * Best-effort extraction of the {@code description} field from YAML frontmatter. + * Returns {@code null} if no frontmatter or no description field is found. + */ + static String extractDescriptionFromFrontmatter(String content) { + if (content == null || content.isBlank()) { + return null; + } + String stripped = content.strip(); + if (!stripped.startsWith("---")) { + return null; + } + int end = stripped.indexOf("---", 3); + if (end < 0) { + return null; + } + String frontmatter = stripped.substring(3, end); + // Simple line-by-line parse for "description:" key + for (String line : frontmatter.split("\n")) { + String trimmed = line.strip(); + if (trimmed.startsWith("description:")) { + return trimmed.substring("description:".length()).strip(); + } + if (trimmed.startsWith("description :")) { + return trimmed.substring("description :".length()).strip(); + } + } + return null; + } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java index 1c1b750b86..bee0ee20ac 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java @@ -28,6 +28,7 @@ private WorkspaceConstants() {} public static final String MEMORY_DIR = "memory"; public static final String SKILLS_DIR = "skills"; + public static final String SUBAGENTS_DIR = "subagents"; public static final String KNOWLEDGE_DIR = "knowledge"; public static final String KNOWLEDGE_MD = "KNOWLEDGE.md"; public static final String RULES_DIR = "rules"; diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java index 51f1d9a0cd..b27ebeb067 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java @@ -24,6 +24,7 @@ import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SESSIONS_DIR; import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SESSIONS_STORE; import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SKILLS_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SUBAGENTS_DIR; import static io.agentscope.harness.agent.workspace.WorkspaceConstants.TASKS_DIR; import com.fasterxml.jackson.core.type.TypeReference; @@ -285,6 +286,10 @@ public Path getSkillsDir() { return workspace.resolve(SKILLS_DIR); } + public Path getSubagentsDir() { + return workspace.resolve(SUBAGENTS_DIR); + } + public Path getKnowledgeDir() { return workspace.resolve(KNOWLEDGE_DIR); } @@ -329,6 +334,98 @@ public List listKnowledgeFiles(RuntimeContext rc) { return result; } + /** + * Lists all subagent declaration files ({@code *.md}) under the {@code subagents/} directory. + * Union of filesystem + local disk, deduplicated by relative path. + */ + public List listSubagentFiles(RuntimeContext rc) { + Set relativePaths = new LinkedHashSet<>(); + + if (filesystem != null) { + GlobResult glob = filesystem.glob(rc, "*.md", SUBAGENTS_DIR); + if (glob.isSuccess() && glob.matches() != null) { + for (FileInfo fi : glob.matches()) { + if (fi.path() != null && !fi.path().isBlank()) { + relativePaths.add(normalizeRelativePath(fi.path().trim())); + } + } + } + } + + Path dir = getSubagentsDir(); + if (Files.isDirectory(dir)) { + try (Stream list = Files.list(dir)) { + list.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".md")) + .forEach( + p -> { + String rel = + workspace + .relativize(p.normalize()) + .toString() + .replace('\\', '/'); + relativePaths.add(rel); + }); + } catch (IOException e) { + log.warn("Failed to list subagent files: {}", e.getMessage()); + } + } + + List result = new ArrayList<>(); + for (String rel : relativePaths) { + result.add(workspace.resolve(rel)); + } + return result; + } + + /** + * Lists all skill directories under the {@code skills/} directory. + * Union of filesystem + local disk, deduplicated by relative path. + */ + public List listSkillDirs(RuntimeContext rc) { + Set relativePaths = new LinkedHashSet<>(); + + if (filesystem != null) { + GlobResult glob = filesystem.glob(rc, "SKILL.md", SKILLS_DIR); + if (glob.isSuccess() && glob.matches() != null) { + for (FileInfo fi : glob.matches()) { + if (fi.path() != null && !fi.path().isBlank()) { + String normalized = normalizeRelativePath(fi.path().trim()); + // Extract the skill directory name (parent of SKILL.md) + int idx = normalized.lastIndexOf('/'); + if (idx > 0) { + relativePaths.add(normalized.substring(0, idx)); + } + } + } + } + } + + Path dir = getSkillsDir(); + if (Files.isDirectory(dir)) { + try (Stream list = Files.list(dir)) { + list.filter(Files::isDirectory) + .forEach( + p -> { + String rel = + workspace + .relativize(p.normalize()) + .toString() + .replace('\\', '/'); + relativePaths.add(rel); + }); + } catch (IOException e) { + log.warn("Failed to list skill directories: {}", e.getMessage()); + } + } + + List result = new ArrayList<>(); + for (String rel : relativePaths) { + result.add(workspace.resolve(rel)); + } + return result; + } + public Path getSessionDir(RuntimeContext rc, String agentId) { return resolveRuntimeDataPath(rc, AGENTS_DIR + "/" + agentId + "/" + SESSIONS_DIR); } diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddlewareTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddlewareTest.java new file mode 100644 index 0000000000..ea2c270476 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/WorkspaceContextMiddlewareTest.java @@ -0,0 +1,92 @@ +/* + * Copyright 2024-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.harness.agent.middleware; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +class WorkspaceContextMiddlewareTest { + + @Test + void extractDescriptionFromFrontmatter_simpleDescription() { + String content = + "---\n" + + "description: Code review specialist\n" + + "workspace:\n" + + " mode: isolated\n" + + "---\n" + + "\n" + + "You are a code review subagent."; + String desc = WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content); + assertNotNull(desc); + assertEquals("Code review specialist", desc); + } + + @Test + void extractDescriptionFromFrontmatter_quotedDescription() { + String content = + "---\n" + + "description: \"A long description with special chars: @#$\"\n" + + "---\n" + + "\n" + + "Body text."; + String desc = WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content); + assertNotNull(desc); + assertEquals("\"A long description with special chars: @#$\"", desc); + } + + @Test + void extractDescriptionFromFrontmatter_noDescription() { + String content = "---\n" + "workspace:\n" + " mode: shared\n" + "---\n" + "\n" + "Body."; + String desc = WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content); + assertNull(desc); + } + + @Test + void extractDescriptionFromFrontmatter_noFrontmatter() { + String content = "Just a plain markdown file without frontmatter."; + String desc = WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content); + assertNull(desc); + } + + @Test + void extractDescriptionFromFrontmatter_nullContent() { + assertNull(WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(null)); + } + + @Test + void extractDescriptionFromFrontmatter_blankContent() { + assertNull(WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(" ")); + } + + @Test + void extractDescriptionFromFrontmatter_unclosedFrontmatter() { + String content = "---\n" + "description: Test\n" + "no closing frontmatter"; + assertNull(WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content)); + } + + @Test + void extractDescriptionFromFrontmatter_emptyDescription() { + String content = "---\n" + "description:\n" + "---\n" + "Body."; + String desc = WorkspaceContextMiddleware.extractDescriptionFromFrontmatter(content); + // Empty description value — should return empty string + assertNotNull(desc); + assertEquals("", desc); + } +}