Skip to content
Merged
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 @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 ->
Expand All @@ -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> webProviderRegistry(
LyPiWebProperties properties,
ObjectMapper objectMapper,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -741,6 +803,35 @@ public SystemPrompt buildSystemPrompt(ResourceSnapshot resources) {
};
}

private static ResourceSnapshot resourceSnapshot(
List<McpServerConfig> mcpServers,
List<ExpertAgentDefinition> 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<String> expertAgentNames(ToolRuntimePort runtime) {
@SuppressWarnings("unchecked")
Map<String, Object> properties = (Map<String, Object>) runtime.resolve("spawn_agent")
.orElseThrow()
.inputSchema()
.value()
.get("properties");
@SuppressWarnings("unchecked")
Map<String, Object> agent = (Map<String, Object>) properties.get("agent");
@SuppressWarnings("unchecked")
List<String> names = (List<String>) agent.get("enum");
return names;
}

private static McpServerConfig mcpServerConfig() {
return new McpServerConfig(
"fake",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -12,6 +13,26 @@ public record ResourceSnapshot(
SkillIndex skillIndex,
List<PromptTemplate> promptTemplates,
List<McpServerConfig> mcpServers,
List<ExpertAgentDefinition> expertAgents,
List<ResourceDiagnostic> 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<ContextFile> agentFiles,
List<MemorySource> memorySources,
SkillIndex skillIndex,
List<PromptTemplate> promptTemplates,
List<McpServerConfig> mcpServers,
List<ResourceDiagnostic> diagnostics
) {
this(agentFiles, memorySources, skillIndex, promptTemplates, mcpServers, List.of(), diagnostics);
}
}
Loading
Loading