Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Indentation inconsistency: buildSubagentsBlock / buildSkillsBlock produce XML blocks whose inner content is not indented, yet buildLoadedContextSection prepends only " " (2 spaces) to the whole block. This yields output like:

  <subagents_context>
Available subagents:
- foo
</subagents_context>

While buildXmlContext (used for agents/memory/knowledge) correctly indents every inner line by 2 spaces.

Suggested fix: apply indentByTwo inside buildSubagentsBlock / buildSkillsBlock (similar to how buildAdditionalContextBlock works), or wrap them via buildXmlContext for consistency.

sb.append(" ").append(subagentsBlock).append("\n");
}
if (!skillsBlock.isBlank()) {
sb.append(" ").append(skillsBlock).append("\n");
}
if (!additionalBlock.isBlank()) {
sb.append(additionalBlock);
}
Expand Down Expand Up @@ -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 :")) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Quoted YAML description values are not unquoted. If frontmatter contains description: "Code review specialist", this line returns the literal string "Code review specialist" (with quote characters), which leaks into the LLM prompt. The test extractDescriptionFromFrontmatter_quotedDescription even asserts this incorrect behavior.

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 assertEquals("A long description with special chars: @#$", desc);.

return trimmed.substring("description :".length()).strip();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The second branch description : (with a space before the colon) is non-standard YAML and unlikely to appear in real frontmatter. While harmless, consider removing it to keep the parser minimal, or document that it's intentional leniency.

}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -329,6 +334,98 @@ public List<Path> 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<Path> listSubagentFiles(RuntimeContext rc) {
Set<String> 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<Path> 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<Path> 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<Path> listSkillDirs(RuntimeContext rc) {
Set<String> 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<Path> 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<Path> 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);
}
Expand Down
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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 (\"A long description...\"). After fixing extractDescriptionFromFrontmatter to strip YAML quotes, update this to:

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