-
Notifications
You must be signed in to change notification settings - Fork 796
fix(workspace): inject subagents and skills summary into workspace co… #1665
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,13 +348,21 @@ private String buildLoadedContextSection( | |
| String agentsContent, | ||
| String memoryContent, | ||
| String knowledgeBlock, | ||
| String subagentsBlock, | ||
| String skillsBlock, | ||
| String additionalBlock, | ||
| RuntimeContext rc) { | ||
| StringBuilder sb = new StringBuilder(); | ||
| sb.append("<loaded_context>\n"); | ||
| 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<Path> subagentFiles = workspaceManager.listSubagentFiles(rc); | ||
| if (subagentFiles.isEmpty()) { | ||
| return ""; | ||
| } | ||
|
|
||
| StringBuilder sb = new StringBuilder(); | ||
| sb.append("<subagents_context>\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("</subagents_context>"); | ||
| 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<Path> skillDirs = workspaceManager.listSkillDirs(rc); | ||
| if (skillDirs.isEmpty()) { | ||
| return ""; | ||
| } | ||
|
|
||
| StringBuilder sb = new StringBuilder(); | ||
| sb.append("<skills_context>\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("</skills_context>"); | ||
| 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 :")) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] Quoted YAML description values are not unquoted. If frontmatter contains Suggested fix — strip matching surrounding quotes: String value = trimmed.substring("description:".length()).strip();
if (value.length() >= 2
&& ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'")))) {
value = value.substring(1, value.length() - 1);
}
return value;Also update the test expectation to |
||
| return trimmed.substring("description :".length()).strip(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nit] The second branch |
||
| } | ||
| } | ||
| return null; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [major] This assertion codifies the buggy behavior: the expected value still includes the literal quote characters ( assertEquals("A long description with special chars: @#$", 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[minor] Indentation inconsistency:
buildSubagentsBlock/buildSkillsBlockproduce XML blocks whose inner content is not indented, yetbuildLoadedContextSectionprepends only" "(2 spaces) to the whole block. This yields output like:While
buildXmlContext(used for agents/memory/knowledge) correctly indents every inner line by 2 spaces.Suggested fix: apply
indentByTwoinsidebuildSubagentsBlock/buildSkillsBlock(similar to howbuildAdditionalContextBlockworks), or wrap them viabuildXmlContextfor consistency.