From 090b59196a9d0fb40ee2055e0c9d6a22a6e7fb5a Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 22 Jul 2026 02:09:09 +0800 Subject: [PATCH 1/5] feat(subagent): define expert agent contracts --- .../contracts/resource/ResourceSnapshot.java | 23 ++- .../session/ChildSessionRequest.java | 39 +++++ .../subagent/ExpertAgentDefinition.java | 18 +++ .../subagent/SubagentSpawnRequest.java | 33 ++++- .../contracts/ContractSerializationTest.java | 140 ++++++++++++++++++ 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 lypi-contracts/src/main/java/cn/lypi/contracts/subagent/ExpertAgentDefinition.java diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/resource/ResourceSnapshot.java b/lypi-contracts/src/main/java/cn/lypi/contracts/resource/ResourceSnapshot.java index 38f41f66..e43ea408 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/resource/ResourceSnapshot.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/resource/ResourceSnapshot.java @@ -4,6 +4,7 @@ import cn.lypi.contracts.memory.MemoryScope; import cn.lypi.contracts.prompt.PromptTemplate; import cn.lypi.contracts.skill.SkillIndex; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import java.util.List; public record ResourceSnapshot( @@ -12,6 +13,26 @@ public record ResourceSnapshot( SkillIndex skillIndex, List promptTemplates, List mcpServers, + List expertAgents, List diagnostics -) {} +) { + public ResourceSnapshot { + agentFiles = agentFiles == null ? List.of() : List.copyOf(agentFiles); + memorySources = memorySources == null ? List.of() : List.copyOf(memorySources); + promptTemplates = promptTemplates == null ? List.of() : List.copyOf(promptTemplates); + mcpServers = mcpServers == null ? List.of() : List.copyOf(mcpServers); + expertAgents = expertAgents == null ? List.of() : List.copyOf(expertAgents); + diagnostics = diagnostics == null ? List.of() : List.copyOf(diagnostics); + } + public ResourceSnapshot( + List agentFiles, + List memorySources, + SkillIndex skillIndex, + List promptTemplates, + List mcpServers, + List diagnostics + ) { + this(agentFiles, memorySources, skillIndex, promptTemplates, mcpServers, List.of(), diagnostics); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ChildSessionRequest.java b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ChildSessionRequest.java index ba2037a4..4ee78367 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/session/ChildSessionRequest.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/session/ChildSessionRequest.java @@ -23,6 +23,7 @@ public record ChildSessionRequest( int depth, Optional agentName, Optional agentRole, + Optional initialSystemPrompt, Optional initialModel, Optional initialThinkingLevel, Optional initialAgentMode, @@ -52,6 +53,7 @@ public ChildSessionRequest( Optional.empty(), Optional.empty(), Optional.empty(), + null, SubagentToolPolicy.empty() ); } @@ -73,12 +75,46 @@ public ChildSessionRequest( cwd = cwd == null ? sessionCwd : cwd; agentName = agentName == null ? Optional.empty() : agentName; agentRole = agentRole == null ? Optional.empty() : agentRole; + initialSystemPrompt = initialSystemPrompt == null ? Optional.empty() : initialSystemPrompt; initialModel = initialModel == null ? Optional.empty() : initialModel; initialThinkingLevel = initialThinkingLevel == null ? Optional.empty() : initialThinkingLevel; initialAgentMode = initialAgentMode == null ? Optional.empty() : initialAgentMode; toolPolicy = toolPolicy == null ? SubagentToolPolicy.empty() : toolPolicy; } + public ChildSessionRequest( + String childSessionId, + String parentSessionId, + String parentSpawnEntryId, + Path sessionCwd, + Path cwd, + int depth, + Optional agentName, + Optional agentRole, + Optional initialModel, + Optional initialThinkingLevel, + Optional initialAgentMode, + PermissionRuntimeState initialPermissionRuntimeState, + SubagentToolPolicy toolPolicy + ) { + this( + childSessionId, + parentSessionId, + parentSpawnEntryId, + sessionCwd, + cwd, + depth, + agentName, + agentRole, + Optional.empty(), + initialModel, + initialThinkingLevel, + initialAgentMode, + initialPermissionRuntimeState, + toolPolicy + ); + } + public ChildSessionRequest( String childSessionId, String parentSessionId, @@ -103,6 +139,7 @@ public ChildSessionRequest( depth, agentName, agentRole, + Optional.empty(), initialModel, initialThinkingLevel, initialAgentMode, @@ -141,6 +178,7 @@ public static ChildSessionRequest create( @JsonProperty("depth") int depth, @JsonProperty("agentName") Optional agentName, @JsonProperty("agentRole") Optional agentRole, + @JsonProperty("initialSystemPrompt") Optional initialSystemPrompt, @JsonProperty("initialModel") Optional initialModel, @JsonProperty("initialThinkingLevel") Optional initialThinkingLevel, @JsonProperty("initialAgentMode") Optional initialAgentMode, @@ -157,6 +195,7 @@ public static ChildSessionRequest create( depth, agentName, agentRole, + initialSystemPrompt, initialModel, initialThinkingLevel, initialAgentMode, diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/ExpertAgentDefinition.java b/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/ExpertAgentDefinition.java new file mode 100644 index 00000000..a9ca266b --- /dev/null +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/ExpertAgentDefinition.java @@ -0,0 +1,18 @@ +package cn.lypi.contracts.subagent; + +import java.nio.file.Path; +import java.util.List; + +public record ExpertAgentDefinition( + String name, + String provider, + String model, + String prompt, + List tools, + Path sourceFile +) { + public ExpertAgentDefinition { + tools = tools == null ? List.of() : List.copyOf(tools); + sourceFile = sourceFile == null ? null : sourceFile.toAbsolutePath().normalize(); + } +} diff --git a/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/SubagentSpawnRequest.java b/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/SubagentSpawnRequest.java index 1b535184..f5416872 100644 --- a/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/SubagentSpawnRequest.java +++ b/lypi-contracts/src/main/java/cn/lypi/contracts/subagent/SubagentSpawnRequest.java @@ -12,12 +12,43 @@ public record SubagentSpawnRequest( List tools, Optional provider, Optional model, - Optional thinkingLevel + Optional thinkingLevel, + Optional agentRole, + Optional initialSystemPrompt ) { public SubagentSpawnRequest { tools = tools == null ? List.of() : List.copyOf(tools); provider = provider == null ? Optional.empty() : provider; model = model == null ? Optional.empty() : model; thinkingLevel = thinkingLevel == null ? Optional.empty() : thinkingLevel; + agentRole = agentRole == null ? Optional.empty() : agentRole; + initialSystemPrompt = initialSystemPrompt == null ? Optional.empty() : initialSystemPrompt; + if (agentRole.isPresent() != initialSystemPrompt.isPresent()) { + throw new IllegalArgumentException("agentRole and initialSystemPrompt must be present together"); + } + } + + public SubagentSpawnRequest( + String parentSessionId, + String parentEntryId, + String taskName, + String message, + List tools, + Optional provider, + Optional model, + Optional thinkingLevel + ) { + this( + parentSessionId, + parentEntryId, + taskName, + message, + tools, + provider, + model, + thinkingLevel, + Optional.empty(), + Optional.empty() + ); } } diff --git a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java index 32490736..7f7ef647 100644 --- a/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java +++ b/lypi-contracts/src/test/java/cn/lypi/contracts/ContractSerializationTest.java @@ -45,6 +45,7 @@ import cn.lypi.contracts.model.ProviderRetryNotice; import cn.lypi.contracts.model.ThinkingLevel; import cn.lypi.contracts.memory.MemoryScope; +import cn.lypi.contracts.resource.ResourceSnapshot; import cn.lypi.contracts.resource.MemorySource; import cn.lypi.contracts.runtime.ExecutionMetadata; import cn.lypi.contracts.runtime.ExecutionRequest; @@ -89,8 +90,10 @@ import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; import cn.lypi.contracts.skill.SkillMention; +import cn.lypi.contracts.skill.SkillIndex; import cn.lypi.contracts.subagent.AgentRunStatus; import cn.lypi.contracts.subagent.AgentView; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.subagent.HeadlessSubagentInput; import cn.lypi.contracts.subagent.HeadlessSubagentOutput; import cn.lypi.contracts.subagent.MailboxMessage; @@ -589,6 +592,57 @@ void subagentSpawnContractsRoundTripKeepOnlyExplicitChildConfiguration() throws assertFalse(requestJson.contains("agentMode")); } + @Test + void subagentSpawnRequestRoundTripKeepsExpertIdentityAndPrompt() throws Exception { + SubagentSpawnRequest request = new SubagentSpawnRequest( + "ses_parent", + "entry_parent", + "review-auth", + "Review auth", + List.of("read", "grep", "glob", "bash"), + Optional.of("openai"), + Optional.of("gpt-5.4"), + Optional.empty(), + Optional.of("code-reviewer"), + Optional.of("Review code precisely.") + ); + + String json = mapper.writeValueAsString(request); + + assertEquals(request, mapper.readValue(json, SubagentSpawnRequest.class)); + assertTrue(json.contains("\"agentRole\":\"code-reviewer\"")); + assertTrue(json.contains("\"initialSystemPrompt\":\"Review code precisely.\"")); + } + + @Test + void expertAgentDefinitionIsImmutableAndIncludedInResourceSnapshot() throws Exception { + List tools = new java.util.ArrayList<>(List.of("bash")); + ExpertAgentDefinition expert = new ExpertAgentDefinition( + "code-reviewer", + "openai", + "gpt-5.4", + "Review code precisely.", + tools, + Path.of("/repo/./.ly-pi/agents/code-reviewer.yaml") + ); + ResourceSnapshot snapshot = new ResourceSnapshot( + List.of(), + List.of(), + new SkillIndex(List.of(), List.of()), + List.of(), + List.of(), + List.of(expert), + List.of() + ); + + tools.add("write"); + + assertEquals(List.of("bash"), expert.tools()); + assertEquals(Path.of("/repo/.ly-pi/agents/code-reviewer.yaml"), expert.sourceFile()); + assertEquals(List.of(expert), snapshot.expertAgents()); + assertEquals(expert, mapper.readValue(mapper.writeValueAsString(expert), ExpertAgentDefinition.class)); + } + @Test void childSessionRequestRoundTripKeepsInitialSubagentMetadata() throws Exception { ChildSessionRequest request = new ChildSessionRequest( @@ -615,6 +669,92 @@ void childSessionRequestRoundTripKeepsInitialSubagentMetadata() throws Exception assertTrue(json.contains("\"toolPolicy\"")); } + @Test + void childSessionRequestRoundTripKeepsInitialExpertPrompt() throws Exception { + ChildSessionRequest request = new ChildSessionRequest( + "ses_child", + "ses_parent", + "entry_spawn", + Path.of("/tmp/project/.ly-pi"), + Path.of("/tmp/project"), + 2, + Optional.of("review-auth"), + Optional.of("code-reviewer"), + Optional.of("Review code precisely."), + Optional.of(new ModelSelection("openai", "gpt-5.4", ThinkingLevel.HIGH)), + Optional.of(ThinkingLevel.HIGH), + Optional.of(AgentMode.EXECUTE), + PermissionRuntimeState.fromLegacy(PermissionMode.ASK), + new SubagentToolPolicy(List.of("read", "bash"), List.of("read", "grep", "glob", "bash")) + ); + + String json = mapper.writeValueAsString(request); + ChildSessionRequest restored = mapper.readValue(json, ChildSessionRequest.class); + + assertEquals(request, restored); + assertEquals(Optional.of("Review code precisely."), restored.initialSystemPrompt()); + } + + @Test + void oldSubagentJsonDefaultsNewExpertFieldsToEmpty() throws Exception { + SubagentSpawnRequest spawn = mapper.readValue( + """ + { + "parentSessionId": "ses_parent", + "parentEntryId": "entry_parent", + "taskName": "review-auth", + "message": "Review auth", + "tools": [], + "provider": null, + "model": null, + "thinkingLevel": null + } + """, + SubagentSpawnRequest.class + ); + ChildSessionRequest child = mapper.readValue( + """ + { + "childSessionId": "ses_child", + "parentSessionId": "ses_parent", + "parentSpawnEntryId": "entry_spawn", + "sessionCwd": "/tmp/project/.ly-pi", + "cwd": "/tmp/project", + "depth": 2, + "agentName": null, + "agentRole": null, + "initialModel": null, + "initialThinkingLevel": null, + "initialAgentMode": null, + "initialPermissionRuntimeState": null, + "initialPermissionMode": null, + "toolPolicy": {"requestedTools": [], "effectiveTools": []} + } + """, + ChildSessionRequest.class + ); + + assertEquals(Optional.empty(), spawn.agentRole()); + assertEquals(Optional.empty(), spawn.initialSystemPrompt()); + assertEquals(Optional.empty(), child.initialSystemPrompt()); + } + + @Test + void subagentSpawnRequestRejectsPartialExpertConfiguration() { + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> new SubagentSpawnRequest( + "ses_parent", + "entry_parent", + "review-auth", + "Review auth", + List.of(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("code-reviewer"), + Optional.empty() + )); + } + @Test void subagentWaitContractsDistinguishAllOutcomes() throws Exception { SubagentWaitRequest request = new SubagentWaitRequest("ses_parent", 600_000); From 23db1688584ec9af44dd1777ee27cfd121c7ac7e Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 22 Jul 2026 02:18:34 +0800 Subject: [PATCH 2/5] feat(resource): load layered expert agent yaml --- .../lypi/resource/DefaultResourceLoader.java | 7 + .../cn/lypi/resource/ExpertAgentScanner.java | 188 +++++++++++++++++ .../resource/ResourceLocationResolver.java | 1 + .../resource/DefaultResourceLoaderTest.java | 36 ++++ .../lypi/resource/ExpertAgentScannerTest.java | 199 ++++++++++++++++++ .../ResourceLocationResolverTest.java | 1 + 6 files changed, 432 insertions(+) create mode 100644 lypi-resource/src/main/java/cn/lypi/resource/ExpertAgentScanner.java create mode 100644 lypi-resource/src/test/java/cn/lypi/resource/ExpertAgentScannerTest.java diff --git a/lypi-resource/src/main/java/cn/lypi/resource/DefaultResourceLoader.java b/lypi-resource/src/main/java/cn/lypi/resource/DefaultResourceLoader.java index 5a9c92ac..d53eb6e6 100644 --- a/lypi-resource/src/main/java/cn/lypi/resource/DefaultResourceLoader.java +++ b/lypi-resource/src/main/java/cn/lypi/resource/DefaultResourceLoader.java @@ -7,6 +7,7 @@ import cn.lypi.contracts.resource.ResourceDiagnostic; import cn.lypi.contracts.resource.ResourceSnapshot; import cn.lypi.contracts.skill.SkillIndex; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -23,6 +24,7 @@ public class DefaultResourceLoader implements ResourceLoader { private final MemorySourceScanner memorySourceScanner; private final SkillScanner skillScanner; private final PromptTemplateScanner promptTemplateScanner; + private final ExpertAgentScanner expertAgentScanner; private final McpConfigScanner mcpConfigScanner; public DefaultResourceLoader() { @@ -41,6 +43,7 @@ public DefaultResourceLoader(List userRoots, List explicitRoots) { new MemorySourceScanner(), new SkillScanner(), new PromptTemplateScanner(), + new ExpertAgentScanner(), new McpConfigScanner() ); } @@ -52,6 +55,7 @@ public DefaultResourceLoader(List userRoots, List explicitRoots) { MemorySourceScanner memorySourceScanner, SkillScanner skillScanner, PromptTemplateScanner promptTemplateScanner, + ExpertAgentScanner expertAgentScanner, McpConfigScanner mcpConfigScanner ) { this.projectRootResolver = projectRootResolver; @@ -60,6 +64,7 @@ public DefaultResourceLoader(List userRoots, List explicitRoots) { this.memorySourceScanner = memorySourceScanner; this.skillScanner = skillScanner; this.promptTemplateScanner = promptTemplateScanner; + this.expertAgentScanner = expertAgentScanner; this.mcpConfigScanner = mcpConfigScanner; } @@ -78,6 +83,7 @@ public ResourceSnapshot load(Path cwd) { List memorySources = memorySourceScanner.scan(discoveryPlan.locations(), diagnostics); SkillIndex skillIndex = skillScanner.scan(discoveryPlan.locations(), diagnostics); List promptTemplates = promptTemplateScanner.scan(discoveryPlan.locations(), diagnostics); + List expertAgents = expertAgentScanner.scan(discoveryPlan.locations(), diagnostics); List mcpServers = mcpConfigScanner.scan(discoveryPlan.locations(), diagnostics); return new ResourceSnapshot( @@ -86,6 +92,7 @@ public ResourceSnapshot load(Path cwd) { skillIndex, promptTemplates, mcpServers, + expertAgents, List.copyOf(diagnostics) ); } diff --git a/lypi-resource/src/main/java/cn/lypi/resource/ExpertAgentScanner.java b/lypi-resource/src/main/java/cn/lypi/resource/ExpertAgentScanner.java new file mode 100644 index 00000000..4fc8035b --- /dev/null +++ b/lypi-resource/src/main/java/cn/lypi/resource/ExpertAgentScanner.java @@ -0,0 +1,188 @@ +package cn.lypi.resource; + +import cn.lypi.contracts.resource.ResourceDiagnostic; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +/** Scans layered expert Agent definitions without exposing YAML details to callers. */ +class ExpertAgentScanner { + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); + private static final Set FIELDS = Set.of("name", "provider", "model", "prompt", "tools"); + + private final YAMLMapper yamlMapper = YAMLMapper.builder() + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .build(); + + List scan( + List locations, + List diagnostics + ) { + Map> selected = new LinkedHashMap<>(); + for (ResourceLocation location : orderedLocations(locations)) { + for (Path file : agentFiles(location, diagnostics)) { + readAgent(location, file, diagnostics, selected); + } + } + return selected.values().stream().map(PrioritizedResource::value).toList(); + } + + private List orderedLocations(List locations) { + return locations.stream() + .filter(location -> switch (location.layer()) { + case USER, PROJECT, NESTED_PROJECT, EXPLICIT_PATH -> true; + case PLATFORM, SESSION, MCP_DERIVED -> false; + }) + .sorted(Comparator.comparingInt(ResourceLocation::priority)) + .toList(); + } + + private List agentFiles(ResourceLocation location, List diagnostics) { + Path root = switch (location.layer()) { + case USER -> location.root().resolve("agents"); + case PROJECT, NESTED_PROJECT, EXPLICIT_PATH -> location.root().resolve(".ly-pi").resolve("agents"); + default -> null; + }; + if (root == null || !Files.isDirectory(root)) { + return List.of(); + } + return ResourceFiles.regularFiles(root, diagnostics).stream() + .filter(this::isYaml) + .toList(); + } + + private boolean isYaml(Path file) { + String name = file.getFileName().toString(); + return name.endsWith(".yaml") || name.endsWith(".yml"); + } + + private void readAgent( + ResourceLocation location, + Path file, + List diagnostics, + Map> selected + ) { + try { + JsonNode root = yamlMapper.readTree(file.toFile()); + validateShape(root); + AgentYaml yaml = yamlMapper.treeToValue(root, AgentYaml.class); + ExpertAgentDefinition agent = toDefinition(yaml, file); + mergeAgent(agent, location, file, diagnostics, selected); + } catch (IOException | RuntimeException exception) { + diagnostics.add(ResourceDiagnostics.warning( + "Failed to parse expert agent: " + exception.getMessage(), + file + )); + } + } + + private void validateShape(JsonNode root) { + if (root == null || !root.isObject()) { + throw new IllegalArgumentException("definition must be a YAML object"); + } + root.fieldNames().forEachRemaining(field -> { + if (!FIELDS.contains(field)) { + throw new IllegalArgumentException("unknown field: " + field); + } + }); + for (String field : List.of("name", "provider", "model", "prompt")) { + JsonNode value = root.get(field); + if (value != null && !value.isNull() && !value.isTextual()) { + throw new IllegalArgumentException(field + " must be a string"); + } + } + JsonNode tools = root.get("tools"); + if (tools == null || tools.isNull()) { + return; + } + if (!tools.isArray()) { + throw new IllegalArgumentException("tools must be an array"); + } + tools.forEach(tool -> { + if (!tool.isTextual()) { + throw new IllegalArgumentException("tools entries must be strings"); + } + }); + } + + private ExpertAgentDefinition toDefinition(AgentYaml yaml, Path file) { + if (yaml == null) { + throw new IllegalArgumentException("definition must be a YAML object"); + } + String name = required("name", yaml.name()); + if (!NAME_PATTERN.matcher(name).matches()) { + throw new IllegalArgumentException("name must match " + NAME_PATTERN.pattern()); + } + List tools = validatedTools(yaml.tools()); + return new ExpertAgentDefinition( + name, + required("provider", yaml.provider()), + required("model", yaml.model()), + required("prompt", yaml.prompt()), + tools, + file + ); + } + + private String required(String field, String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must be a non-empty string"); + } + return value.trim(); + } + + private List validatedTools(List tools) { + if (tools == null || tools.isEmpty()) { + return List.of(); + } + List validated = new ArrayList<>(tools.size()); + for (String tool : tools) { + validated.add(required("tools entry", tool)); + } + return List.copyOf(validated); + } + + private void mergeAgent( + ExpertAgentDefinition agent, + ResourceLocation location, + Path file, + List diagnostics, + Map> selected + ) { + PrioritizedResource existing = selected.get(agent.name()); + if (existing == null) { + selected.put(agent.name(), new PrioritizedResource<>(agent, location.priority(), location)); + return; + } + if (location.priority() >= existing.priority()) { + diagnostics.add(ResourceDiagnostics.warning("expert agent override: " + agent.name(), file)); + selected.put(agent.name(), new PrioritizedResource<>(agent, location.priority(), location)); + } else { + diagnostics.add(ResourceDiagnostics.warning( + "expert agent shadowed by higher priority definition: " + agent.name(), + file + )); + } + } + + private record AgentYaml( + String name, + String provider, + String model, + String prompt, + List tools + ) {} +} diff --git a/lypi-resource/src/main/java/cn/lypi/resource/ResourceLocationResolver.java b/lypi-resource/src/main/java/cn/lypi/resource/ResourceLocationResolver.java index 775195dc..630d8c8d 100644 --- a/lypi-resource/src/main/java/cn/lypi/resource/ResourceLocationResolver.java +++ b/lypi-resource/src/main/java/cn/lypi/resource/ResourceLocationResolver.java @@ -173,6 +173,7 @@ private static Path ensureDefaultUserRoot(Path root) { Files.createDirectories(normalized.resolve("memory")); Files.createDirectories(normalized.resolve("skills")); Files.createDirectories(normalized.resolve("prompts")); + Files.createDirectories(normalized.resolve("agents")); createFileIfMissing(normalized.resolve("application.yml"), DEFAULT_APPLICATION_YML); createFileIfMissing(normalized.resolve("memory.md"), DEFAULT_MEMORY_INDEX); createFileIfMissing( diff --git a/lypi-resource/src/test/java/cn/lypi/resource/DefaultResourceLoaderTest.java b/lypi-resource/src/test/java/cn/lypi/resource/DefaultResourceLoaderTest.java index 0bd614c7..963cfc6f 100644 --- a/lypi-resource/src/test/java/cn/lypi/resource/DefaultResourceLoaderTest.java +++ b/lypi-resource/src/test/java/cn/lypi/resource/DefaultResourceLoaderTest.java @@ -234,6 +234,42 @@ void loadDiscoversDotLyPiDirectorySkillsFromCurrentWorkingDirectory() throws Exc }); } + @Test + void loadDiscoversLayeredExpertAgentsThroughCurrentWorkingDirectory() throws Exception { + Path root = Files.createDirectories(tempDir.resolve("repo")); + Path module = Files.createDirectories(root.resolve("module")); + Files.writeString(root.resolve(".git"), "gitdir: /tmp/repo.git"); + Path projectAgent = root.resolve(".ly-pi/agents/code-reviewer.yaml"); + Files.createDirectories(projectAgent.getParent()); + Files.writeString(projectAgent, """ + name: code-reviewer + provider: openai + model: project-model + prompt: Project prompt + """); + Path nestedAgent = module.resolve(".ly-pi/agents/code-reviewer.yml"); + Files.createDirectories(nestedAgent.getParent()); + Files.writeString(nestedAgent, """ + name: code-reviewer + provider: anthropic + model: nested-model + prompt: Nested prompt + tools: + - bash + """); + + ResourceSnapshot snapshot = new DefaultResourceLoader(List.of(), List.of()).load(module); + + assertThat(snapshot.expertAgents()).singleElement().satisfies(agent -> { + assertThat(agent.provider()).isEqualTo("anthropic"); + assertThat(agent.model()).isEqualTo("nested-model"); + assertThat(agent.tools()).containsExactly("bash"); + assertThat(agent.sourceFile()).isEqualTo(nestedAgent.toAbsolutePath().normalize()); + }); + assertThat(snapshot.diagnostics()) + .anySatisfy(diagnostic -> assertThat(diagnostic.message()).isEqualTo("expert agent override: code-reviewer")); + } + @Test void loadReportsDiagnosticsForInvalidResourcesWithoutFailingSnapshot() throws Exception { Path root = Files.createDirectories(tempDir.resolve("repo")); diff --git a/lypi-resource/src/test/java/cn/lypi/resource/ExpertAgentScannerTest.java b/lypi-resource/src/test/java/cn/lypi/resource/ExpertAgentScannerTest.java new file mode 100644 index 00000000..2b103308 --- /dev/null +++ b/lypi-resource/src/test/java/cn/lypi/resource/ExpertAgentScannerTest.java @@ -0,0 +1,199 @@ +package cn.lypi.resource; + +import static org.assertj.core.api.Assertions.assertThat; + +import cn.lypi.contracts.resource.ResourceDiagnostic; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExpertAgentScannerTest { + @TempDir + Path tempDir; + + @Test + void higherPriorityNestedAgentOverridesProjectAndUserDefinitions() throws Exception { + Path user = Files.createDirectories(tempDir.resolve("user/.ly-pi")); + Path project = Files.createDirectories(tempDir.resolve("repo")); + Path nested = Files.createDirectories(project.resolve("module")); + writeAgent(user.resolve("agents/code-reviewer.yaml"), "user-model", "User prompt", List.of("bash")); + writeAgent(project.resolve(".ly-pi/agents/code-reviewer.yml"), "project-model", "Project prompt", List.of("read")); + Path nestedFile = nested.resolve(".ly-pi/agents/review/code-reviewer.yaml"); + writeAgent(nestedFile, "nested-model", "Nested prompt", List.of("bash", "write")); + List diagnostics = new ArrayList<>(); + + var agents = new ExpertAgentScanner().scan(List.of( + new ResourceLocation(ResourceLayer.USER, user, 100, "user"), + new ResourceLocation(ResourceLayer.PROJECT, project, 200, "project"), + new ResourceLocation(ResourceLayer.NESTED_PROJECT, nested, 300, "nested") + ), diagnostics); + + assertThat(agents).singleElement().satisfies(agent -> { + assertThat(agent.name()).isEqualTo("code-reviewer"); + assertThat(agent.provider()).isEqualTo("openai"); + assertThat(agent.model()).isEqualTo("nested-model"); + assertThat(agent.prompt()).isEqualTo("Nested prompt"); + assertThat(agent.tools()).containsExactly("bash", "write"); + assertThat(agent.sourceFile()).isEqualTo(nestedFile.toAbsolutePath().normalize()); + }); + assertThat(diagnostics) + .filteredOn(diagnostic -> diagnostic.message().contains("expert agent override: code-reviewer")) + .hasSize(2); + } + + @Test + void malformedUnknownOrInvalidYamlProducesDiagnosticsWithoutBlockingValidAgents() throws Exception { + Path project = Files.createDirectories(tempDir.resolve("repo")); + Path agentRoot = Files.createDirectories(project.resolve(".ly-pi/agents")); + Files.writeString(agentRoot.resolve("valid.yaml"), """ + name: valid-agent + provider: openai + model: gpt-5.4 + prompt: Valid prompt + """); + Files.writeString(agentRoot.resolve("malformed.yaml"), "name: ["); + Files.writeString(agentRoot.resolve("unknown.yaml"), """ + name: unknown-field + provider: openai + model: gpt-5.4 + prompt: Prompt + temperature: 0.2 + """); + Files.writeString(agentRoot.resolve("missing.yaml"), """ + name: missing-prompt + provider: openai + model: gpt-5.4 + """); + Files.writeString(agentRoot.resolve("invalid-name.yaml"), """ + name: Code Reviewer + provider: openai + model: gpt-5.4 + prompt: Prompt + """); + Files.writeString(agentRoot.resolve("invalid-tools.yaml"), """ + name: invalid-tools + provider: openai + model: gpt-5.4 + prompt: Prompt + tools: + - 42 + """); + Files.writeString(agentRoot.resolve("blank-tool.yaml"), """ + name: blank-tool + provider: openai + model: gpt-5.4 + prompt: Prompt + tools: + - " " + """); + List diagnostics = new ArrayList<>(); + + var agents = new ExpertAgentScanner().scan(List.of( + new ResourceLocation(ResourceLayer.PROJECT, project, 200, "project") + ), diagnostics); + + assertThat(agents).extracting(agent -> agent.name()).containsExactly("valid-agent"); + assertThat(diagnostics).hasSize(6).allSatisfy(diagnostic -> { + assertThat(diagnostic.message()).startsWith("Failed to parse expert agent:"); + assertThat(diagnostic.path()).isPresent(); + }); + assertThat(diagnostics) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("malformed.yaml"))) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("unknown.yaml"))) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("missing.yaml"))) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("invalid-name.yaml"))) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("invalid-tools.yaml"))) + .anySatisfy(diagnostic -> assertThat(diagnostic.path().orElseThrow()).endsWith(Path.of("blank-tool.yaml"))); + } + + @Test + void missingNullOrEmptyToolsUseNoAdditionalToolsAndBothYamlExtensionsAreAccepted() throws Exception { + Path project = Files.createDirectories(tempDir.resolve("repo")); + Path agentRoot = Files.createDirectories(project.resolve(".ly-pi/agents/nested")); + Files.writeString(agentRoot.resolve("missing.yaml"), agentYaml("missing-tools", "missing-model", "Missing", "")); + Files.writeString(agentRoot.resolve("null.yml"), agentYaml("null-tools", "null-model", "Null tools", "tools:\n")); + Files.writeString(agentRoot.resolve("empty.yaml"), agentYaml("empty-tools", "empty-model", "Empty", "tools: []\n")); + List diagnostics = new ArrayList<>(); + + var agents = new ExpertAgentScanner().scan(List.of( + new ResourceLocation(ResourceLayer.PROJECT, project, 200, "project") + ), diagnostics); + + assertThat(diagnostics).isEmpty(); + assertThat(agents) + .extracting(agent -> agent.name()) + .containsExactlyInAnyOrder("missing-tools", "null-tools", "empty-tools"); + assertThat(agents).allSatisfy(agent -> assertThat(agent.tools()).isEmpty()); + } + + @Test + void laterFileInSameLayerOverridesEarlierFileDeterministically() throws Exception { + Path project = Files.createDirectories(tempDir.resolve("repo")); + Path agentRoot = Files.createDirectories(project.resolve(".ly-pi/agents")); + Files.writeString(agentRoot.resolve("a.yaml"), agentYaml("code-reviewer", "first-model", "First", "")); + Path laterFile = agentRoot.resolve("z.yml"); + Files.writeString(laterFile, agentYaml("code-reviewer", "later-model", "Later", "")); + List diagnostics = new ArrayList<>(); + + var agents = new ExpertAgentScanner().scan(List.of( + new ResourceLocation(ResourceLayer.PROJECT, project, 200, "project") + ), diagnostics); + + assertThat(agents).singleElement().satisfies(agent -> { + assertThat(agent.model()).isEqualTo("later-model"); + assertThat(agent.sourceFile()).isEqualTo(laterFile.toAbsolutePath().normalize()); + }); + assertThat(diagnostics).singleElement().satisfies(diagnostic -> + assertThat(diagnostic.message()).isEqualTo("expert agent override: code-reviewer") + ); + } + + @Test + void explicitAgentOverridesDeepestNestedDefinition() throws Exception { + Path project = Files.createDirectories(tempDir.resolve("repo")); + Path nested = Files.createDirectories(project.resolve("module")); + Path explicit = Files.createDirectories(tempDir.resolve("explicit")); + writeAgent(nested.resolve(".ly-pi/agents/code-reviewer.yaml"), "nested-model", "Nested", List.of()); + Path explicitFile = explicit.resolve(".ly-pi/agents/code-reviewer.yaml"); + writeAgent(explicitFile, "explicit-model", "Explicit", List.of("bash")); + List diagnostics = new ArrayList<>(); + + var agents = new ExpertAgentScanner().scan(List.of( + new ResourceLocation(ResourceLayer.NESTED_PROJECT, nested, 300, "nested"), + new ResourceLocation(ResourceLayer.EXPLICIT_PATH, explicit, 400, "explicit") + ), diagnostics); + + assertThat(agents).singleElement().satisfies(agent -> { + assertThat(agent.model()).isEqualTo("explicit-model"); + assertThat(agent.sourceFile()).isEqualTo(explicitFile.toAbsolutePath().normalize()); + }); + assertThat(diagnostics).singleElement().satisfies(diagnostic -> + assertThat(diagnostic.message()).isEqualTo("expert agent override: code-reviewer") + ); + } + + private void writeAgent(Path file, String model, String prompt, List tools) throws Exception { + Files.createDirectories(file.getParent()); + String toolLines = tools.stream().map(tool -> " - " + tool).collect(java.util.stream.Collectors.joining("\n")); + Files.writeString(file, """ + name: code-reviewer + provider: openai + model: %s + prompt: %s + tools: + %s + """.formatted(model, prompt, toolLines)); + } + + private String agentYaml(String name, String model, String prompt, String tools) { + return """ + name: %s + provider: openai + model: %s + prompt: %s + %s""".formatted(name, model, prompt, tools); + } +} diff --git a/lypi-resource/src/test/java/cn/lypi/resource/ResourceLocationResolverTest.java b/lypi-resource/src/test/java/cn/lypi/resource/ResourceLocationResolverTest.java index 71724314..96bda130 100644 --- a/lypi-resource/src/test/java/cn/lypi/resource/ResourceLocationResolverTest.java +++ b/lypi-resource/src/test/java/cn/lypi/resource/ResourceLocationResolverTest.java @@ -60,6 +60,7 @@ void defaultResolverCreatesUserLyPiDefaultsWhenMissing() throws Exception { assertThat(userRoot.resolve("memories")).doesNotExist(); assertThat(userRoot.resolve("skills")).isDirectory(); assertThat(userRoot.resolve("prompts")).isDirectory(); + assertThat(userRoot.resolve("agents")).isDirectory().isEmptyDirectory(); assertThat(userRoot.resolve("skills/memory-settlement/SKILL.md")).exists(); assertThat(Files.readString(userRoot.resolve("memory.md"))) .contains("~/.ly-pi/memory/") From 5ff54663ada33714aec6d5e031d30372ddb8d466 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 22 Jul 2026 02:31:05 +0800 Subject: [PATCH 3/5] feat(tool): select expert agents in spawn --- .../cn/lypi/tool/builtin/BuiltInTools.java | 27 ++++- .../builtin/subagent/ExpertAgentResolver.java | 73 ++++++++++++ .../tool/builtin/subagent/SpawnAgentTool.java | 49 ++++++-- .../builtin/subagent/SubagentToolInputs.java | 2 + .../lypi/tool/builtin/BuiltInToolsTest.java | 25 +++++ .../subagent/ExpertAgentResolverTest.java | 92 +++++++++++++++ .../builtin/subagent/SubagentToolsTest.java | 106 +++++++++++++++++- 7 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolver.java create mode 100644 lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolverTest.java diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java index 414c34a3..1c7a5c4e 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/BuiltInTools.java @@ -3,6 +3,7 @@ import cn.lypi.contracts.runtime.Executor; import cn.lypi.contracts.runtime.AgentCenterPort; import cn.lypi.contracts.runtime.ToolRuntimePort; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.tool.Tool; import cn.lypi.tool.builtin.subagent.SpawnAgentTool; import cn.lypi.tool.builtin.subagent.WaitAgentTool; @@ -186,10 +187,21 @@ public static void registerWebFetchTool( * 创建模型可见的 subagent 工具集合。 */ public static List> createSubagentTools(ToolRuntimePort runtime, AgentCenterPort agentCenter) { + return createSubagentTools(runtime, agentCenter, List.of()); + } + + /** + * 创建带启动时专家目录的 subagent 工具集合。 + */ + public static List> createSubagentTools( + ToolRuntimePort runtime, + AgentCenterPort agentCenter, + List expertAgents + ) { Objects.requireNonNull(runtime, "runtime must not be null"); Objects.requireNonNull(agentCenter, "agentCenter must not be null"); return List.of( - new SpawnAgentTool(runtime, agentCenter), + new SpawnAgentTool(runtime, agentCenter, expertAgents), new WaitAgentTool(agentCenter) ); } @@ -198,8 +210,19 @@ public static void registerWebFetchTool( * 注册模型可见的 subagent 工具集合。 */ public static void registerSubagentTools(ToolRuntimePort runtime, AgentCenterPort agentCenter) { + registerSubagentTools(runtime, agentCenter, List.of()); + } + + /** + * 注册带启动时专家目录的 subagent 工具集合。 + */ + public static void registerSubagentTools( + ToolRuntimePort runtime, + AgentCenterPort agentCenter, + List expertAgents + ) { Objects.requireNonNull(runtime, "runtime must not be null"); - for (Tool tool : createSubagentTools(runtime, agentCenter)) { + for (Tool tool : createSubagentTools(runtime, agentCenter, expertAgents)) { runtime.register(tool); } } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolver.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolver.java new file mode 100644 index 00000000..adf3c281 --- /dev/null +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolver.java @@ -0,0 +1,73 @@ +package cn.lypi.tool.builtin.subagent; + +import cn.lypi.contracts.subagent.ExpertAgentDefinition; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +final class ExpertAgentResolver { + private final Map agents; + private final List names; + + ExpertAgentResolver(List definitions) { + Map byName = new LinkedHashMap<>(); + for (ExpertAgentDefinition definition : definitions == null ? List.of() : definitions) { + ExpertAgentDefinition agent = Objects.requireNonNull(definition, "expert agent must not be null"); + if (byName.putIfAbsent(agent.name(), agent) != null) { + throw new IllegalArgumentException("重复的专家 Agent: " + agent.name()); + } + } + this.agents = Map.copyOf(byName); + this.names = byName.keySet().stream().sorted().toList(); + } + + List names() { + return names; + } + + Resolved resolve(Map input) { + Optional expert = SubagentToolInputs.optionalString(input, "agent") + .map(this::requireAgent); + List requestedTools = input != null && input.containsKey("tools") + ? SubagentToolInputs.tools(input) + : expert.map(ExpertAgentDefinition::tools).orElseGet(List::of); + Optional provider = SubagentToolInputs.optionalString(input, "provider") + .or(() -> expert.map(ExpertAgentDefinition::provider)); + Optional model = SubagentToolInputs.optionalString(input, "model") + .or(() -> expert.map(ExpertAgentDefinition::model)); + return new Resolved( + provider, + model, + requestedTools, + expert.map(ExpertAgentDefinition::name), + expert.map(ExpertAgentDefinition::prompt) + ); + } + + private ExpertAgentDefinition requireAgent(String name) { + ExpertAgentDefinition agent = agents.get(name); + if (agent != null) { + return agent; + } + String available = names.isEmpty() ? "无" : String.join(", ", names); + throw new IllegalArgumentException("专家 Agent 不存在: " + name + "。可用值: " + available); + } + + record Resolved( + Optional provider, + Optional model, + List requestedTools, + Optional agentRole, + Optional initialSystemPrompt + ) { + Resolved { + provider = provider == null ? Optional.empty() : provider; + model = model == null ? Optional.empty() : model; + requestedTools = requestedTools == null ? List.of() : List.copyOf(requestedTools); + agentRole = agentRole == null ? Optional.empty() : agentRole; + initialSystemPrompt = initialSystemPrompt == null ? Optional.empty() : initialSystemPrompt; + } + } +} diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SpawnAgentTool.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SpawnAgentTool.java index 63b0a760..fb413411 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SpawnAgentTool.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SpawnAgentTool.java @@ -6,6 +6,7 @@ import cn.lypi.contracts.common.ValidationResult; import cn.lypi.contracts.runtime.AgentCenterPort; import cn.lypi.contracts.runtime.ToolRuntimePort; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.subagent.SubagentRunStatus; import cn.lypi.contracts.subagent.SubagentSpawnRequest; import cn.lypi.contracts.subagent.SubagentSpawnResult; @@ -20,10 +21,20 @@ public final class SpawnAgentTool extends AbstractSubagentTool { private final AgentCenterPort agentCenter; private final SubagentToolPolicyNormalizer toolPolicyNormalizer; + private final ExpertAgentResolver expertAgentResolver; public SpawnAgentTool(ToolRuntimePort toolRuntime, AgentCenterPort agentCenter) { + this(toolRuntime, agentCenter, List.of()); + } + + public SpawnAgentTool( + ToolRuntimePort toolRuntime, + AgentCenterPort agentCenter, + List expertAgents + ) { this.agentCenter = Objects.requireNonNull(agentCenter, "agentCenter must not be null"); this.toolPolicyNormalizer = new SubagentToolPolicyNormalizer(toolRuntime); + this.expertAgentResolver = new ExpertAgentResolver(expertAgents); } @Override @@ -33,7 +44,8 @@ public String name() { @Override public String description() { - return "启动一个 prompt-only subagent。默认继承当前 Agent 的 provider、model 与 thinking level;仅在明确需要覆盖时填写这些可选参数。" + return "启动一个 prompt-only subagent。可选择已配置的专家 Agent;专家提供 provider、model、prompt 与 tools 默认值,显式参数可覆盖。" + + "未选择专家时默认继承当前 Agent 的 provider、model 与 thinking level;仅在明确需要覆盖时填写这些可选参数。" + "read、grep、glob 固定可用,tools 只追加 canonical 工具名。启动后继续执行其他独立工作;completion 会在后续模型边界自动投递。" + "仅当下一步依赖结果且没有其他可执行工作时才调用 wait_agent;用户要求继续时不要调用 wait_agent。"; } @@ -43,6 +55,11 @@ public JsonSchema inputSchema() { Map properties = new LinkedHashMap<>(); properties.put("task_name", Map.of("type", "string")); properties.put("message", Map.of("type", "string")); + properties.put("agent", Map.of( + "type", "string", + "enum", expertAgentResolver.names(), + "description", "可选专家 Agent 名;使用其 provider、model、prompt 与 tools 默认配置,显式参数可覆盖默认值。" + )); properties.put("tools", Map.of("type", "array", "items", Map.of("type", "string"))); properties.put("provider", SubagentToolSchemas.providerSchema()); properties.put("model", SubagentToolSchemas.modelSchema()); @@ -62,7 +79,7 @@ public ValidationResult validateInput(Map input, ToolUseContext return fields; } try { - toolPolicyNormalizer.normalize(SubagentToolInputs.tools(input)); + resolveInput(input); return fields; } catch (IllegalArgumentException exception) { return new ValidationResult(false, List.of(exception.getMessage())); @@ -72,21 +89,23 @@ public ValidationResult validateInput(Map input, ToolUseContext @Override public ToolResult execute(Map input, ToolUseContext context, ProgressSink progress) { try { - ValidationResult validation = validateInput(input, context); - if (!validation.valid()) { - return error(context, String.join(" ", validation.messages())); + ValidationResult fields = SubagentToolInputs.validateSpawn(input); + if (!fields.valid()) { + return error(context, String.join(" ", fields.messages())); } + ResolvedSpawn resolved = resolveInput(input); progress.progress(ToolProgress.phase("spawning", "启动 subagent")); - SubagentToolPolicy policy = toolPolicyNormalizer.normalize(SubagentToolInputs.tools(input)); SubagentSpawnResult result = agentCenter.spawn(new SubagentSpawnRequest( context.sessionId(), parentEntryId(context), SubagentToolInputs.requiredString(input, "task_name"), SubagentToolInputs.requiredString(input, "message"), - policy.effectiveTools(), - SubagentToolInputs.optionalString(input, "provider"), - SubagentToolInputs.optionalString(input, "model"), - SubagentToolInputs.thinkingLevel(input) + resolved.policy().effectiveTools(), + resolved.configuration().provider(), + resolved.configuration().model(), + SubagentToolInputs.thinkingLevel(input), + resolved.configuration().agentRole(), + resolved.configuration().initialSystemPrompt() )); if (result.status() == SubagentRunStatus.FAILED) { return error(context, result.message().orElse("subagent 启动失败。")); @@ -120,4 +139,14 @@ private String parentEntryId(ToolUseContext context) { Object value = context.metadata().get("parentEntryId"); return value instanceof String parentEntryId && !parentEntryId.isBlank() ? parentEntryId : null; } + + private ResolvedSpawn resolveInput(Map input) { + ExpertAgentResolver.Resolved expert = expertAgentResolver.resolve(input); + return new ResolvedSpawn(expert, toolPolicyNormalizer.normalize(expert.requestedTools())); + } + + private record ResolvedSpawn( + ExpertAgentResolver.Resolved configuration, + SubagentToolPolicy policy + ) {} } diff --git a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SubagentToolInputs.java b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SubagentToolInputs.java index 5b8d2356..8b46fbd7 100644 --- a/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SubagentToolInputs.java +++ b/lypi-tool/src/main/java/cn/lypi/tool/builtin/subagent/SubagentToolInputs.java @@ -15,6 +15,7 @@ final class SubagentToolInputs { private static final Set SPAWN_FIELDS = Set.of( "task_name", "message", + "agent", "tools", "provider", "model", @@ -29,6 +30,7 @@ static ValidationResult validateSpawn(Map input) { List errors = exactFields(input, SPAWN_FIELDS); requireString(input, "task_name", errors); requireString(input, "message", errors); + validateOptionalString(input, "agent", errors); validateOptionalString(input, "provider", errors); validateOptionalString(input, "model", errors); tryValue(errors, () -> tools(input)); diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BuiltInToolsTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BuiltInToolsTest.java index 468408ee..6ce2bfd2 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/BuiltInToolsTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/BuiltInToolsTest.java @@ -11,6 +11,7 @@ import cn.lypi.contracts.security.PermissionDecisionReason; import cn.lypi.contracts.security.PermissionUpdate; import cn.lypi.contracts.subagent.MailboxCommandResult; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.subagent.SubagentSpawnRequest; import cn.lypi.contracts.subagent.SubagentSpawnResult; import cn.lypi.contracts.subagent.SubagentWaitRequest; @@ -22,6 +23,7 @@ import cn.lypi.contracts.web.WebSearchResponse; import cn.lypi.tool.DefaultToolRuntime; import cn.lypi.tool.web.WebStoredResult; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Optional; @@ -94,6 +96,29 @@ void registersSubagentToolsIntoRuntime() { assertTrue(runtime.resolve("read_mailbox").isEmpty()); } + @Test + void createsSubagentToolsWithFrozenExpertDirectory() { + DefaultToolRuntime runtime = toolRuntime(); + BuiltInTools.registerDefaults(runtime, executor()); + ExpertAgentDefinition expert = new ExpertAgentDefinition( + "code-reviewer", + "openai", + "gpt-5.4", + "Review code precisely.", + List.of("bash"), + Path.of("/repo/.ly-pi/agents/code-reviewer.yaml") + ); + + List> tools = BuiltInTools.createSubagentTools(runtime, agentCenter(), List.of(expert)); + Tool spawn = tools.stream().filter(tool -> tool.name().equals("spawn_agent")).findFirst().orElseThrow(); + @SuppressWarnings("unchecked") + Map properties = (Map) spawn.inputSchema().value().get("properties"); + @SuppressWarnings("unchecked") + Map agentSchema = (Map) properties.get("agent"); + + assertEquals(List.of("code-reviewer"), agentSchema.get("enum")); + } + @Test void registersWebToolsOnce() { DefaultToolRuntime runtime = new DefaultToolRuntime((request, context) -> diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolverTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolverTest.java new file mode 100644 index 00000000..d01d5043 --- /dev/null +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/ExpertAgentResolverTest.java @@ -0,0 +1,92 @@ +package cn.lypi.tool.builtin.subagent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import cn.lypi.contracts.subagent.ExpertAgentDefinition; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ExpertAgentResolverTest { + @Test + void selectedExpertProvidesConfiguredDefaults() { + ExpertAgentResolver resolver = new ExpertAgentResolver(List.of(expert())); + + ExpertAgentResolver.Resolved resolved = resolver.resolve(Map.of("agent", "code-reviewer")); + + assertEquals(Optional.of("openai"), resolved.provider()); + assertEquals(Optional.of("gpt-5.4"), resolved.model()); + assertEquals(List.of("bash"), resolved.requestedTools()); + assertEquals(Optional.of("code-reviewer"), resolved.agentRole()); + assertEquals(Optional.of("Review code precisely."), resolved.initialSystemPrompt()); + assertEquals(List.of("code-reviewer"), resolver.names()); + } + + @Test + void unselectedExpertKeepsGenericSpawnValues() { + ExpertAgentResolver resolver = new ExpertAgentResolver(List.of(expert())); + + ExpertAgentResolver.Resolved resolved = resolver.resolve(Map.of( + "provider", "anthropic", + "model", "claude-opus", + "tools", List.of("write") + )); + + assertEquals(Optional.of("anthropic"), resolved.provider()); + assertEquals(Optional.of("claude-opus"), resolved.model()); + assertEquals(List.of("write"), resolved.requestedTools()); + assertEquals(Optional.empty(), resolved.agentRole()); + assertEquals(Optional.empty(), resolved.initialSystemPrompt()); + } + + @Test + void explicitValuesOverrideExpertAndEmptyToolsReplaceConfiguredTools() { + ExpertAgentResolver resolver = new ExpertAgentResolver(List.of(expert())); + + ExpertAgentResolver.Resolved resolved = resolver.resolve(Map.of( + "agent", "code-reviewer", + "provider", " ", + "model", "gpt-5.4-mini", + "tools", List.of() + )); + + assertEquals(Optional.of("openai"), resolved.provider()); + assertEquals(Optional.of("gpt-5.4-mini"), resolved.model()); + assertEquals(List.of(), resolved.requestedTools()); + assertEquals(Optional.of("code-reviewer"), resolved.agentRole()); + assertEquals(Optional.of("Review code precisely."), resolved.initialSystemPrompt()); + } + + @Test + void unknownOrDuplicateExpertNamesFailFast() { + ExpertAgentResolver resolver = new ExpertAgentResolver(List.of(expert())); + + IllegalArgumentException unknown = assertThrows( + IllegalArgumentException.class, + () -> resolver.resolve(Map.of("agent", "missing-agent")) + ); + IllegalArgumentException duplicate = assertThrows( + IllegalArgumentException.class, + () -> new ExpertAgentResolver(List.of(expert(), expert())) + ); + + assertTrue(unknown.getMessage().contains("missing-agent")); + assertTrue(unknown.getMessage().contains("code-reviewer")); + assertTrue(duplicate.getMessage().contains("code-reviewer")); + } + + private ExpertAgentDefinition expert() { + return new ExpertAgentDefinition( + "code-reviewer", + "openai", + "gpt-5.4", + "Review code precisely.", + List.of("bash"), + Path.of("/repo/.ly-pi/agents/code-reviewer.yaml") + ); + } +} diff --git a/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java index 1ed8c6e5..ba3f0eb0 100644 --- a/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java +++ b/lypi-tool/src/test/java/cn/lypi/tool/builtin/subagent/SubagentToolsTest.java @@ -13,6 +13,7 @@ import cn.lypi.contracts.runtime.ExecutionResult; import cn.lypi.contracts.runtime.Executor; import cn.lypi.contracts.subagent.MailboxCommandResult; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.subagent.SubagentRunStatus; import cn.lypi.contracts.subagent.SubagentSpawnRequest; import cn.lypi.contracts.subagent.SubagentSpawnResult; @@ -39,13 +40,26 @@ void spawnSchemaContainsOnlyFinalCanonicalParameters() { Map properties = (Map) tool.inputSchema().value().get("properties"); assertEquals( - List.of("message", "model", "provider", "task_name", "thinking_level", "tools"), + List.of("agent", "message", "model", "provider", "task_name", "thinking_level", "tools"), properties.keySet().stream().sorted().toList() ); assertEquals(List.of("task_name", "message"), tool.inputSchema().value().get("required")); assertEquals(false, tool.inputSchema().value().get("additionalProperties")); } + @Test + void spawnSchemaListsConfiguredExpertAgents() { + SpawnAgentTool tool = new SpawnAgentTool(runtime(), new RecordingAgentCenter(), List.of(expert())); + + @SuppressWarnings("unchecked") + Map properties = (Map) tool.inputSchema().value().get("properties"); + @SuppressWarnings("unchecked") + Map agentSchema = (Map) properties.get("agent"); + + assertEquals(List.of("code-reviewer"), agentSchema.get("enum")); + assertEquals(List.of("task_name", "message"), tool.inputSchema().value().get("required")); + } + @Test void spawnModelOverridesAreDocumentedAsOptionalInheritedValues() { SpawnAgentTool tool = new SpawnAgentTool(runtime(), new RecordingAgentCenter()); @@ -109,6 +123,85 @@ void spawnPassesCanonicalEffectiveToolsAndIndependentModelOptions() { assertTrue(result.output().contains("用户要求继续时不要等待")); } + @Test + void spawnUsesSelectedExpertDefaultsAndCarriesInternalPrompt() { + RecordingAgentCenter center = new RecordingAgentCenter(); + SpawnAgentTool tool = new SpawnAgentTool(runtime(), center, List.of(expert())); + + ToolResult result = tool.execute(Map.of( + "task_name", "review-auth", + "message", "Review auth changes.", + "agent", "code-reviewer", + "model", "gpt-5.4-mini" + ), context(), ignored -> { + }); + + assertFalse(result.isError(), result.output()); + assertEquals("Review auth changes.", center.spawnRequest.message()); + assertEquals(List.of("read", "grep", "glob", "bash"), center.spawnRequest.tools()); + assertEquals(Optional.of("openai"), center.spawnRequest.provider()); + assertEquals(Optional.of("gpt-5.4-mini"), center.spawnRequest.model()); + assertEquals(Optional.of("code-reviewer"), center.spawnRequest.agentRole()); + assertEquals(Optional.of("Review code precisely."), center.spawnRequest.initialSystemPrompt()); + } + + @Test + void spawnExplicitEmptyToolsAndNonBlankModelValuesOverrideExpertDefaults() { + RecordingAgentCenter center = new RecordingAgentCenter(); + SpawnAgentTool tool = new SpawnAgentTool(runtime(), center, List.of(expert())); + + ToolResult result = tool.execute(Map.of( + "task_name", "review-auth", + "message", "Review auth changes.", + "agent", "code-reviewer", + "provider", "anthropic", + "model", " ", + "tools", List.of() + ), context(), ignored -> { + }); + + assertFalse(result.isError(), result.output()); + assertEquals(List.of("read", "grep", "glob"), center.spawnRequest.tools()); + assertEquals(Optional.of("anthropic"), center.spawnRequest.provider()); + assertEquals(Optional.of("gpt-5.4"), center.spawnRequest.model()); + } + + @Test + void spawnRejectsUnknownExpertsAndInvalidConfiguredToolsBeforeStartingChild() { + RecordingAgentCenter unknownCenter = new RecordingAgentCenter(); + SpawnAgentTool unknownTool = new SpawnAgentTool(runtime(), unknownCenter, List.of(expert())); + ExpertAgentDefinition invalidExpert = new ExpertAgentDefinition( + "invalid-expert", + "openai", + "gpt-5.4", + "Prompt", + List.of("missing_tool"), + Path.of("/repo/.ly-pi/agents/invalid-expert.yaml") + ); + RecordingAgentCenter invalidCenter = new RecordingAgentCenter(); + SpawnAgentTool invalidTool = new SpawnAgentTool(runtime(), invalidCenter, List.of(invalidExpert)); + + ToolResult unknown = unknownTool.execute(Map.of( + "task_name", "review-auth", + "message", "Review auth changes.", + "agent", "missing-agent" + ), context(), ignored -> { + }); + ToolResult invalid = invalidTool.execute(Map.of( + "task_name", "review-auth", + "message", "Review auth changes.", + "agent", "invalid-expert" + ), context(), ignored -> { + }); + + assertTrue(unknown.isError()); + assertTrue(unknown.output().contains("missing-agent")); + assertTrue(invalid.isError()); + assertTrue(invalid.output().contains("missing_tool")); + assertEquals(null, unknownCenter.spawnRequest); + assertEquals(null, invalidCenter.spawnRequest); + } + @Test void spawnTreatsBlankModelOverridesAsOmitted() { RecordingAgentCenter center = new RecordingAgentCenter(); @@ -271,6 +364,17 @@ private ToolUseContext context(AbortSignal abortSignal, SteeringMessageSource st ); } + private ExpertAgentDefinition expert() { + return new ExpertAgentDefinition( + "code-reviewer", + "openai", + "gpt-5.4", + "Review code precisely.", + List.of("bash"), + Path.of("/repo/.ly-pi/agents/code-reviewer.yaml") + ); + } + private static final class RecordingAgentCenter implements AgentCenterPort { private SubagentSpawnRequest spawnRequest; private SubagentWaitRequest waitRequest; From 0e5d09e715ca33258adecba974423e45fef96b79 Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 22 Jul 2026 02:35:59 +0800 Subject: [PATCH 4/5] feat(subagent): persist expert prompt in child session --- .../runtime/subagent/DefaultAgentCenter.java | 3 +- .../subagent/DefaultAgentCenterTest.java | 32 +++++++++++++ .../cn/lypi/session/ChildSessionService.java | 13 ++++++ .../lypi/session/ChildSessionServiceTest.java | 45 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lypi-runtime/src/main/java/cn/lypi/runtime/subagent/DefaultAgentCenter.java b/lypi-runtime/src/main/java/cn/lypi/runtime/subagent/DefaultAgentCenter.java index 5e4cf361..4104f5fe 100644 --- a/lypi-runtime/src/main/java/cn/lypi/runtime/subagent/DefaultAgentCenter.java +++ b/lypi-runtime/src/main/java/cn/lypi/runtime/subagent/DefaultAgentCenter.java @@ -100,7 +100,8 @@ public SubagentSpawnResult spawn(SubagentSpawnRequest request) { parentCwd, 1, Optional.of(request.taskName()), - Optional.empty(), + request.agentRole(), + request.initialSystemPrompt(), Optional.of(effectiveModel), Optional.of(effectiveModel.thinkingLevel()), Optional.ofNullable(parentContext.mode()), diff --git a/lypi-runtime/src/test/java/cn/lypi/runtime/subagent/DefaultAgentCenterTest.java b/lypi-runtime/src/test/java/cn/lypi/runtime/subagent/DefaultAgentCenterTest.java index 90ac1fef..285381af 100644 --- a/lypi-runtime/src/test/java/cn/lypi/runtime/subagent/DefaultAgentCenterTest.java +++ b/lypi-runtime/src/test/java/cn/lypi/runtime/subagent/DefaultAgentCenterTest.java @@ -96,6 +96,38 @@ void omittedModelFieldsInheritIndependentlyFromParent() { ); } + @Test + void expertSpawnPassesRoleAndPromptOnlyToChildSession() { + Fixture fixture = fixture(PermissionRuntimeState.forMode(PermissionMode.ASK), catalog( + descriptor("anthropic", "expert-model", true) + )); + SubagentSpawnRequest request = new SubagentSpawnRequest( + "ses_parent", + "entry_parent", + "review-auth", + "Review auth changes.", + List.of("read", "grep", "glob", "bash"), + Optional.of("anthropic"), + Optional.of("expert-model"), + Optional.of(ThinkingLevel.HIGH), + Optional.of("code-reviewer"), + Optional.of("Review code precisely.") + ); + + SubagentSpawnResult result = fixture.center.spawn(request); + + assertThat(result.status()).isEqualTo(SubagentRunStatus.STARTED); + ChildSessionRequest child = fixture.children.last(); + assertThat(child.agentRole()).contains("code-reviewer"); + assertThat(child.initialSystemPrompt()).contains("Review code precisely."); + assertThat(child.initialModel()).contains(new ModelSelection("anthropic", "expert-model", ThinkingLevel.HIGH)); + assertThat(child.toolPolicy().effectiveTools()).containsExactly("read", "grep", "glob", "bash"); + assertThat(fixture.process.input.message()).isEqualTo("Review auth changes."); + assertThat(HeadlessSubagentInput.class.getRecordComponents()) + .extracting(java.lang.reflect.RecordComponent::getName) + .doesNotContain("agentRole", "initialSystemPrompt"); + } + @Test void explicitUnknownModelFailsBeforeCreatingChildSession() { Fixture fixture = fixture(PermissionRuntimeState.forMode(PermissionMode.ASK), catalog( diff --git a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java index 643ceba4..449d77e1 100644 --- a/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java +++ b/lypi-session/src/main/java/cn/lypi/session/ChildSessionService.java @@ -2,6 +2,7 @@ import cn.lypi.contracts.runtime.ChildSessionPort; import cn.lypi.contracts.session.ChildSessionRequest; +import cn.lypi.contracts.session.CustomMessageEntry; import cn.lypi.contracts.session.SessionHandle; import cn.lypi.contracts.session.SessionHeader; import cn.lypi.contracts.session.SessionInfoEntry; @@ -63,6 +64,18 @@ public SessionHandle create(ChildSessionRequest request) { ); index.add(info); store.append(request.childSessionId(), info); + request.initialSystemPrompt() + .filter(prompt -> !prompt.isBlank()) + .ifPresent(prompt -> { + CustomMessageEntry instruction = new CustomMessageEntry( + SessionEntryIds.newEntryId(), + index.leafId(), + prompt, + now + ); + index.add(instruction); + store.append(request.childSessionId(), instruction); + }); return new SessionHandle( request.childSessionId(), store.sessionFile(request.childSessionId()), diff --git a/lypi-session/src/test/java/cn/lypi/session/ChildSessionServiceTest.java b/lypi-session/src/test/java/cn/lypi/session/ChildSessionServiceTest.java index 8a07b038..1a4411cb 100644 --- a/lypi-session/src/test/java/cn/lypi/session/ChildSessionServiceTest.java +++ b/lypi-session/src/test/java/cn/lypi/session/ChildSessionServiceTest.java @@ -3,10 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import cn.lypi.contracts.context.MessageRole; import cn.lypi.contracts.model.ModelSelection; import cn.lypi.contracts.model.ThinkingLevel; import cn.lypi.contracts.security.AgentMode; import cn.lypi.contracts.security.PermissionMode; +import cn.lypi.contracts.security.PermissionRuntimeState; import cn.lypi.contracts.session.ChildSessionRequest; import cn.lypi.contracts.session.CustomMessageEntry; import cn.lypi.contracts.session.SessionHandle; @@ -106,6 +108,49 @@ void createChildSessionWritesInitialModelContextIntoHeader() throws Exception { assertThat(header.initialPermissionMode()).contains(PermissionMode.ASK); } + @Test + void createChildSessionPersistsInitialExpertPromptAsSystemLocalMessage() throws Exception { + SessionManager parent = new SessionManagerImpl(tempDir); + parent.openOrCreate("ses_parent"); + ChildSessionService service = new ChildSessionService(Clock.fixed(NOW, ZoneOffset.UTC)); + + SessionHandle child = service.create(new ChildSessionRequest( + "ses_child", + "ses_parent", + "entry_spawn", + tempDir, + tempDir, + 1, + Optional.of("review-auth"), + Optional.of("code-reviewer"), + Optional.of("Review code precisely."), + Optional.empty(), + Optional.empty(), + Optional.empty(), + PermissionRuntimeState.forMode(PermissionMode.AUTO), + new SubagentToolPolicy(List.of(), List.of("read", "grep", "glob")) + )); + + List entries = new JsonlSessionStore(tempDir) + .read(child.sessionId()) + .entries(); + assertThat(entries).hasSize(2); + assertThat(entries.get(0)).isInstanceOf(SessionInfoEntry.class); + assertThat(entries.get(1)).isInstanceOfSatisfying(CustomMessageEntry.class, prompt -> { + assertThat(prompt.parentId()).isEqualTo(entries.get(0).id()); + assertThat(prompt.content()).isEqualTo("Review code precisely."); + assertThat(child.leafId()).isEqualTo(prompt.id()); + }); + + SessionManager childManager = new SessionManagerImpl(tempDir); + childManager.openOrCreate(child.sessionId()); + var context = childManager.context(child.leafId()); + assertThat(context.messages()).singleElement().satisfies(message -> { + assertThat(message.role()).isEqualTo(MessageRole.SYSTEM_LOCAL); + assertThat(message.content().getFirst().text()).isEqualTo("Review code precisely."); + }); + } + @Test void createNestedChildSessionIncrementsParentHeaderDepth() throws Exception { SessionManager parent = new SessionManagerImpl(tempDir); From 73c47f139b4c3c39e255986f8fe828906761f00d Mon Sep 17 00:00:00 2001 From: lyfmt Date: Wed, 22 Jul 2026 02:51:29 +0800 Subject: [PATCH 5/5] feat(boot): load expert agents for spawn at startup --- .../boot/tool/LyPiToolAutoConfiguration.java | 36 ++++-- .../boot/SubagentRuntimeEndToEndTest.java | 84 ++++++++++++++ .../tool/LyPiToolAutoConfigurationTest.java | 109 ++++++++++++++++-- 3 files changed, 212 insertions(+), 17 deletions(-) diff --git a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java index 37af5dde..30ae4122 100644 --- a/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java +++ b/lypi-boot/src/main/java/cn/lypi/boot/tool/LyPiToolAutoConfiguration.java @@ -4,6 +4,7 @@ import cn.lypi.contracts.runtime.AiProviderRuntimePort; import cn.lypi.contracts.event.EventBus; import cn.lypi.contracts.mcp.McpTransport; +import cn.lypi.contracts.resource.ResourceSnapshot; import cn.lypi.contracts.runtime.Executor; import cn.lypi.contracts.runtime.NetworkMode; import cn.lypi.contracts.runtime.ResourceRuntimePort; @@ -229,6 +230,7 @@ private ToolRuntimePort createRuntime( PermissionPromptPort runtimePromptPort ) { Path runtimeCwd = cwd == null ? Path.of(configuredCwd) : cwd; + ResourceSnapshot resources = loadResources(resolvedResourceRuntime, runtimeCwd); ToolRuntimeOptions options = ToolRuntimeOptions.builder() .cwd(runtimeCwd) .build(); @@ -258,9 +260,19 @@ private ToolRuntimePort createRuntime( ); AgentCenterPort resolvedAgentCenter = agentCenter.getIfAvailable(); if (resolvedAgentCenter != null) { - BuiltInTools.registerSubagentTools(runtime, resolvedAgentCenter); + BuiltInTools.registerSubagentTools( + runtime, + resolvedAgentCenter, + resources == null ? List.of() : resources.expertAgents() + ); } - registerMcpTools(runtime, runtimeCwd, resolvedResourceRuntime, resolvedMcpClientManagerFactory, mcpClientManagerLifecycle); + registerMcpTools( + runtime, + runtimeCwd, + resources, + resolvedMcpClientManagerFactory, + mcpClientManagerLifecycle + ); return runtime; } @@ -426,18 +438,14 @@ private PermissionGate permissionGate(EventBus eventBus, PermissionPromptPort pr private void registerMcpTools( ToolRuntimePort runtime, Path cwd, - ResourceRuntimePort resourceRuntime, + ResourceSnapshot resources, McpClientManagerFactory managerFactory, McpClientManagerLifecycle managerLifecycle ) { - if (resourceRuntime == null || managerFactory == null) { + if (resources == null || resources.mcpServers().isEmpty() || managerFactory == null) { return; } try { - cn.lypi.contracts.resource.ResourceSnapshot resources = resourceRuntime.load(cwd); - if (resources == null || resources.mcpServers() == null || resources.mcpServers().isEmpty()) { - return; - } McpClientManager manager = managerFactory.create(cwd); managerLifecycle.track(manager); manager.connectAll(resources.mcpServers()).forEach(schema -> @@ -448,6 +456,18 @@ private void registerMcpTools( } } + private ResourceSnapshot loadResources(ResourceRuntimePort resourceRuntime, Path cwd) { + if (resourceRuntime == null) { + return null; + } + try { + return resourceRuntime.load(cwd); + } catch (RuntimeException exception) { + // NOTE: 资源加载失败不能阻断内置工具和通用 subagent。 + return null; + } + } + private Optional webProviderRegistry( LyPiWebProperties properties, ObjectMapper objectMapper, diff --git a/lypi-boot/src/test/java/cn/lypi/boot/SubagentRuntimeEndToEndTest.java b/lypi-boot/src/test/java/cn/lypi/boot/SubagentRuntimeEndToEndTest.java index 92476353..731c1c8c 100644 --- a/lypi-boot/src/test/java/cn/lypi/boot/SubagentRuntimeEndToEndTest.java +++ b/lypi-boot/src/test/java/cn/lypi/boot/SubagentRuntimeEndToEndTest.java @@ -26,11 +26,15 @@ import cn.lypi.contracts.event.MessageEndEvent; import cn.lypi.contracts.event.ToolProgressEvent; import cn.lypi.contracts.common.SignalSubscription; +import cn.lypi.contracts.model.ApiStyle; import cn.lypi.contracts.model.AssistantDone; import cn.lypi.contracts.model.AssistantEventStream; import cn.lypi.contracts.model.AssistantStart; import cn.lypi.contracts.model.AssistantStreamEvent; import cn.lypi.contracts.model.AssistantStreamResult; +import cn.lypi.contracts.model.CostProfile; +import cn.lypi.contracts.model.ModelCatalogPort; +import cn.lypi.contracts.model.ModelDescriptor; import cn.lypi.contracts.model.ToolCallDelta; import cn.lypi.contracts.prompt.SystemPrompt; import cn.lypi.contracts.runtime.AgentCommunicationPort; @@ -58,10 +62,13 @@ import cn.lypi.runtime.subagent.DefaultMailboxService; import cn.lypi.runtime.subagent.SubagentProcessHandle; import cn.lypi.runtime.subagent.SubagentProcessRunner; +import cn.lypi.session.SessionTreeQuery; import cn.lypi.transport.headless.HeadlessSubagentJsonCodec; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; @@ -154,6 +161,83 @@ void registeredSpawnAndWaitToolsRoundTripThroughHeadlessRunnerExactlyOnce() { }); } + @Test + void configuredExpertAgentFlowsFromYamlIntoChildSession() throws Exception { + Path agentDirectory = tempDir.resolve(".ly-pi").resolve("agents"); + Files.createDirectories(agentDirectory); + Files.writeString(agentDirectory.resolve("code-reviewer.yaml"), """ + name: code-reviewer + provider: expert-provider + model: expert-model + prompt: | + Review code precisely. + Report concrete findings only. + tools: + - bash + """); + CapturingChildAgentCoreFactory childFactory = new CapturingChildAgentCoreFactory(); + RecordingParentAiProvider parentAi = new RecordingParentAiProvider(); + ModelCatalogPort modelCatalog = selection -> { + if (!"expert-provider".equals(selection.provider()) + || !"expert-model-override".equals(selection.modelId())) { + return Optional.empty(); + } + return Optional.of(new ModelDescriptor( + selection.provider(), + selection.modelId(), + URI.create("https://example.invalid"), + ApiStyle.CUSTOM, + 128_000, + 8_192, + true, + false, + new CostProfile(BigDecimal.ZERO, BigDecimal.ZERO, "USD"), + Map.of() + )); + }; + + contextRunner(childFactory, parentAi) + .withBean(ModelCatalogPort.class, () -> modelCatalog) + .run(context -> { + SessionManagerPort sessions = context.getBean(SessionManagerPort.class); + String parentEntryId = prepareParentSession(sessions); + + ToolResult spawn = executeTool( + context.getBean(ToolRuntimePort.class), + sessions, + "turn_expert_spawn", + parentEntryId, + new ToolUseRequest( + "toolu_expert_spawn", + "spawn_agent", + Map.of( + "task_name", "review-auth", + "message", "Review the authentication changes.", + "agent", "code-reviewer", + "model", "expert-model-override", + "tools", List.of() + ), + "msg_parent_history" + ) + ); + + assertThat(spawn.isError()).isFalse(); + assertThat(childFactory.request.get().userInput()) + .isEqualTo("Review the authentication changes."); + assertThat(childFactory.initialContext.get().messages()).singleElement().satisfies(message -> { + assertThat(message.role()).isEqualTo(MessageRole.SYSTEM_LOCAL); + assertThat(message.content().getFirst().text()) + .isEqualTo("Review code precisely.\nReport concrete findings only."); + }); + assertThat(childFactory.initialContext.get().model().provider()).isEqualTo("expert-provider"); + assertThat(childFactory.initialContext.get().model().modelId()).isEqualTo("expert-model-override"); + assertThat(childFactory.toolPolicy.get().effectiveTools()).containsExactly("read", "grep", "glob"); + assertThat(new SessionTreeQuery(tempDir).children(PARENT_SESSION_ID)) + .singleElement() + .satisfies(child -> assertThat(child.agentRole()).contains("code-reviewer")); + }); + } + @Test void completionWithoutWaitIsInjectedAtNextParentModelBoundaryAsSystemLocal() { CapturingChildAgentCoreFactory childFactory = new CapturingChildAgentCoreFactory(); diff --git a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java index a8461045..c43e46b1 100644 --- a/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java +++ b/lypi-boot/src/test/java/cn/lypi/boot/tool/LyPiToolAutoConfigurationTest.java @@ -56,10 +56,11 @@ import cn.lypi.contracts.tool.ToolResult; import cn.lypi.contracts.tool.ToolUseContext; import cn.lypi.contracts.tool.ToolUseRequest; -import cn.lypi.contracts.subagent.SubagentToolPolicy; +import cn.lypi.contracts.subagent.ExpertAgentDefinition; import cn.lypi.contracts.subagent.MailboxCommandResult; import cn.lypi.contracts.subagent.SubagentSpawnRequest; import cn.lypi.contracts.subagent.SubagentSpawnResult; +import cn.lypi.contracts.subagent.SubagentToolPolicy; import cn.lypi.contracts.subagent.SubagentWaitRequest; import cn.lypi.contracts.subagent.SubagentWaitResult; import cn.lypi.tool.PermissionGateResult; @@ -86,6 +87,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.runner.ApplicationContextRunner; @@ -563,6 +565,68 @@ void registersMcpToolsFromResourceRuntime() { }); } + @Test + void loadsResourcesOnceForExpertAndMcpToolRegistration() { + RecordingMcpClientFactory mcpClients = new RecordingMcpClientFactory(); + AtomicInteger loadCalls = new AtomicInteger(); + ResourceSnapshot resources = resourceSnapshot( + List.of(mcpServerConfig()), + List.of(new ExpertAgentDefinition( + "code-reviewer", + "openai", + "gpt-5.4", + "Review code precisely.", + List.of("bash"), + Path.of("agents", "code-reviewer.yaml") + )) + ); + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .withBean(AgentCenterPort.class, LyPiToolAutoConfigurationTest::agentCenter) + .withBean(ResourceRuntimePort.class, () -> resourceRuntimeWith(resources, loadCalls)) + .withBean(McpClientManagerFactory.class, () -> cwd -> mcpClients.manager(cwd)) + .run(context -> { + ToolRuntimePort runtime = context.getBean(ToolRuntimePort.class); + + assertThat(loadCalls).hasValue(1); + assertThat(expertAgentNames(runtime)).containsExactly("code-reviewer"); + assertThat(runtime.resolve("mcp__fake__echo")).isPresent(); + }); + } + + @Test + void resourceLoadFailureKeepsDefaultAndGenericSubagentToolsAvailable() { + AtomicInteger loadCalls = new AtomicInteger(); + ResourceRuntimePort failingResources = new ResourceRuntimePort() { + @Override + public ResourceSnapshot load(Path cwd) { + loadCalls.incrementAndGet(); + throw new IllegalStateException("resource unavailable"); + } + + @Override + public SystemPrompt buildSystemPrompt(ResourceSnapshot resources) { + throw new AssertionError("system prompt must not be built during tool registration"); + } + }; + + new ApplicationContextRunner() + .withUserConfiguration(LyPiToolAutoConfiguration.class) + .withBean(SecurityRuntimePort.class, () -> LyPiToolAutoConfigurationTest::allowAllSecurity) + .withBean(AgentCenterPort.class, LyPiToolAutoConfigurationTest::agentCenter) + .withBean(ResourceRuntimePort.class, () -> failingResources) + .run(context -> { + ToolRuntimePort runtime = context.getBean(ToolRuntimePort.class); + + assertThat(loadCalls).hasValue(1); + assertThat(runtime.resolve("bash")).isPresent(); + assertThat(runtime.resolve("spawn_agent")).isPresent(); + assertThat(expertAgentNames(runtime)).isEmpty(); + }); + } + @Test void closesMcpManagersWhenContextCloses() { RecordingMcpClientFactory mcpClients = new RecordingMcpClientFactory(); @@ -721,17 +785,15 @@ private static ContextSnapshot context(PermissionMode permissionMode) { } private static ResourceRuntimePort resourceRuntimeWith(McpServerConfig config) { + return resourceRuntimeWith(resourceSnapshot(List.of(config), List.of()), new AtomicInteger()); + } + + private static ResourceRuntimePort resourceRuntimeWith(ResourceSnapshot snapshot, AtomicInteger loadCalls) { return new ResourceRuntimePort() { @Override public ResourceSnapshot load(Path cwd) { - return new ResourceSnapshot( - List.of(), - List.of(), - new cn.lypi.contracts.skill.SkillIndex(List.of(), List.of()), - List.of(), - List.of(config), - List.of() - ); + loadCalls.incrementAndGet(); + return snapshot; } @Override @@ -741,6 +803,35 @@ public SystemPrompt buildSystemPrompt(ResourceSnapshot resources) { }; } + private static ResourceSnapshot resourceSnapshot( + List mcpServers, + List expertAgents + ) { + return new ResourceSnapshot( + List.of(), + List.of(), + new cn.lypi.contracts.skill.SkillIndex(List.of(), List.of()), + List.of(), + mcpServers, + expertAgents, + List.of() + ); + } + + private static List expertAgentNames(ToolRuntimePort runtime) { + @SuppressWarnings("unchecked") + Map properties = (Map) runtime.resolve("spawn_agent") + .orElseThrow() + .inputSchema() + .value() + .get("properties"); + @SuppressWarnings("unchecked") + Map agent = (Map) properties.get("agent"); + @SuppressWarnings("unchecked") + List names = (List) agent.get("enum"); + return names; + } + private static McpServerConfig mcpServerConfig() { return new McpServerConfig( "fake",