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); + } +}