From 473ed5786ee06e60df02286729f700ec29ed13c1 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 18 Aug 2026 05:25:22 -0400 Subject: [PATCH] feat(agent): integrate DeepSeek Harness runtime --- .gitignore | 1 + .../java/vip/mate/agent/AgentService.java | 72 +++ .../vip/mate/agent/model/AgentEntity.java | 8 + .../agent/runtime/AgentRuntimeController.java | 10 + .../agent/runtime/RuntimeEventProjector.java | 63 +++ .../runtime/RuntimeEventStreamAdapter.java | 15 + .../runtime/RuntimeProviderConfiguration.java | 25 + .../contract/AgentRuntimeConnection.java | 17 + .../contract/AgentRuntimeCoordinator.java | 24 + .../contract/AgentRuntimeProvider.java | 11 + .../runtime/contract/RuntimeCapabilities.java | 8 + .../runtime/contract/RuntimeContextUsage.java | 9 + .../agent/runtime/contract/RuntimeEvent.java | 38 ++ .../runtime/contract/RuntimeEventLog.java | 41 ++ .../runtime/contract/RuntimeEventType.java | 20 + .../contract/RuntimeProviderRegistry.java | 41 ++ .../agent/runtime/contract/RuntimeResult.java | 24 + .../runtime/contract/RuntimeSession.java | 20 + .../contract/RuntimeSessionFactory.java | 75 +++ .../runtime/contract/RuntimeValidation.java | 11 + .../agent/runtime/dsh/DshBinaryResolver.java | 9 + .../runtime/dsh/DshBridgeAuthenticator.java | 21 + .../runtime/dsh/DshBridgeConnection.java | 65 +++ .../agent/runtime/dsh/DshBridgeEvents.java | 45 ++ .../agent/runtime/dsh/DshBridgeMessage.java | 26 + .../agent/runtime/dsh/DshBridgeMethods.java | 15 + .../agent/runtime/dsh/DshBridgeProtocol.java | 33 ++ .../agent/runtime/dsh/DshBridgeRequests.java | 56 ++ .../agent/runtime/dsh/DshManagedProcess.java | 66 +++ .../runtime/dsh/DshProcessDiagnostics.java | 11 + .../agent/runtime/dsh/DshProcessHandle.java | 11 + .../agent/runtime/dsh/DshProcessLauncher.java | 10 + .../agent/runtime/dsh/DshProcessManager.java | 70 +++ .../agent/runtime/dsh/DshRuntimeService.java | 494 ++++++++++++++++++ .../agent/runtime/dsh/DshToolCatalog.java | 22 + .../agent/runtime/dsh/DshToolDecision.java | 7 + .../agent/runtime/dsh/DshToolDescriptor.java | 9 + .../runtime/dsh/DshToolDispatchResult.java | 15 + .../agent/runtime/dsh/DshToolDispatcher.java | 42 ++ .../mate/agent/runtime/dsh/DshToolPolicy.java | 21 + .../runtime/dsh/DshToolPolicyEvaluator.java | 27 + .../src/main/resources/application.yml | 7 + .../h2/V186__agent_runtime_provider.sql | 10 + .../kingbase/V186__agent_runtime_provider.sql | 13 + .../mysql/V186__agent_runtime_provider.sql | 22 + .../agent/AgentServiceUniquenessTest.java | 31 ++ .../runtime/RuntimeEventProjectorTest.java | 47 ++ .../RuntimeEventStreamAdapterTest.java | 25 + .../contract/AgentRuntimeCoordinatorTest.java | 34 ++ .../RuntimeContractInvariantTest.java | 40 ++ .../runtime/contract/RuntimeEventLogTest.java | 38 ++ .../contract/RuntimeProviderRegistryTest.java | 57 ++ .../contract/RuntimeSessionFactoryTest.java | 77 +++ .../runtime/dsh/DshBridgeEventsTest.java | 26 + .../runtime/dsh/DshBridgeProtocolTest.java | 72 +++ .../runtime/dsh/DshBridgeRequestsTest.java | 41 ++ .../runtime/dsh/DshProcessManagerTest.java | 86 +++ .../agent/runtime/dsh/DshToolCatalogTest.java | 34 ++ .../runtime/dsh/DshToolDispatcherTest.java | 59 +++ .../dsh/DshToolPolicyEvaluatorTest.java | 41 ++ mateclaw-ui/src/api/index.ts | 1 + mateclaw-ui/src/i18n/locales/en-US.ts | 13 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 13 + mateclaw-ui/src/types/index.ts | 2 + mateclaw-ui/src/views/AgentCreateWizard.vue | 20 + mateclaw-ui/src/views/Agents.vue | 76 +++ mateclaw-ui/src/views/ChatConsole.vue | 30 ++ 67 files changed, 2523 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java diff --git a/.gitignore b/.gitignore index 46ce1ec5..3cc8b82e 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ mateclaw-server/src/main/resources/static/ # mateclaw local runtime data (H2 DB, logs, etc. - do not commit) mateclaw-server/data/ +.sessions/ /data/ # VitePress build output and cache (do not commit) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 08c4327c..555ab058 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -27,8 +27,10 @@ import vip.mate.workspace.conversation.model.ConversationEntity; import vip.mate.workspace.conversation.repository.ConversationMapper; import java.util.List; +import java.util.Locale; import java.time.Duration; import java.util.Map; +import java.nio.file.Path; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Function; import java.util.function.Supplier; @@ -78,6 +80,13 @@ public class AgentService { @Autowired(required = false) private ProgressLedgerService progressLedgerService; + /** Runtime SPI coordinator. Native agents remain the default. */ + @Autowired(required = false) + private vip.mate.agent.runtime.contract.AgentRuntimeCoordinator runtimeCoordinator; + + @Autowired(required = false) + private vip.mate.agent.runtime.dsh.DshRuntimeService dshRuntimeService; + /** * Runtime Agent instance cache. Keyed first by agentId, then by a model * key, so a conversation that pins a non-default model gets its own graph @@ -132,6 +141,16 @@ public class AgentService { if (agent.getAgentType() == null) { agent.setAgentType("react"); } + if (!StringUtils.hasText(agent.getRuntimeType())) { + agent.setRuntimeType("native"); + } else { + agent.setRuntimeType(agent.getRuntimeType().trim().toLowerCase(Locale.ROOT)); + } + if (!"native".equals(agent.getRuntimeType()) && !"dsh".equals(agent.getRuntimeType())) { + throw new MateClawException("err.agent.runtime_unsupported", 400, + "Unsupported runtime provider: " + agent.getRuntimeType()); + } + validateDshConfiguration(agent); requireUniqueName(agent, null); agentMapper.insert(agent); publishLifecycle(agent, "spawned"); @@ -156,6 +175,9 @@ public class AgentService { } requireUniqueName(agent, agent.getId()); } + if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) { + validateDshConfiguration(agent); + } agentMapper.updateById(agent); agentInstances.remove(agent.getId()); if (prior != null && prior.getEnabled() != null @@ -299,6 +321,10 @@ public class AgentService { public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + return collectChatResult(chatStructuredStream(agentId, message, conversationId, + "", null, origin != null ? origin : ChatOrigin.EMPTY)).content(); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY); try { @@ -336,6 +362,12 @@ public class AgentService { public Flux chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + return chatStructuredStream(agentId, message, conversationId, "", null, + origin != null ? origin : ChatOrigin.EMPTY) + .filter(delta -> delta.content() != null) + .map(StreamDelta::content); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // Capture the origin into a request-scoped holder; cleared on Flux // termination so the next reactive subscriber doesn't inherit stale state. @@ -373,6 +405,19 @@ public class AgentService { ChatOrigin origin) { clearAutoRecordedForNewTurn(conversationId); memoryRecallTracker.trackRecalls(agentId, message); + if (isDshAgent(agentId)) { + AgentEntity dshAgent = getAgent(agentId); + return withLifecycleFlux(agentId, message, conversationId, + (msg, convId) -> Flux.using( + () -> runtimeCoordinator.start(dshAgent, convId, convId, + dshAgent.getModelName(), dshWorkingDirectory(dshAgent), + dshWorkingDirectory(dshAgent)), + connection -> vip.mate.agent.runtime.RuntimeEventStreamAdapter.adapt( + connection.prompt(msg)), + connection -> connection.close()), + StreamDelta::content) + .doFinally(signal -> ThinkingLevelHolder.clear()); + } BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId); // 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行) @@ -689,6 +734,33 @@ public class AgentService { } } + private boolean isDshAgent(Long agentId) { + if (runtimeCoordinator == null || agentId == null) return false; + AgentEntity entity = getAgent(agentId); + return "dsh".equalsIgnoreCase(entity.getRuntimeType()); + } + + private void validateDshConfiguration(AgentEntity agent) { + if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return; + if (dshRuntimeService == null) { + throw new MateClawException("err.agent.runtime_unavailable", 503, + "DSH runtime provider is unavailable"); + } + try { + dshRuntimeService.validateAgentConfiguration(agent); + } catch (IllegalArgumentException error) { + throw new MateClawException("err.agent.runtime_invalid", 400, error.getMessage()); + } + } + + private Path dshWorkingDirectory(AgentEntity agent) { + String configured = System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir")); + if (agent.getWorkspaceBasePath() != null && !agent.getWorkspaceBasePath().isBlank()) { + configured = agent.getWorkspaceBasePath().trim(); + } + return Path.of(configured).toAbsolutePath().normalize(); + } + /** * Prepend memory-context block to user message if non-empty. * Does not pollute build-time system prompt snapshot. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java index 3a49340c..a00cb8d7 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java @@ -26,6 +26,14 @@ public class AgentEntity { /** Agent 类型:react / plan_execute */ private String agentType; + /** Runtime provider type: native / dsh / other registered providers. */ + @TableField(value = "runtime_type") + private String runtimeType; + + /** Runtime-specific JSON configuration. Null means provider defaults. */ + @TableField(value = "runtime_config", updateStrategy = FieldStrategy.ALWAYS) + private String runtimeConfig; + /** 系统提示词 */ @TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS) private String systemPrompt; diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java index 362fc89b..e46f21f9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/AgentRuntimeController.java @@ -19,6 +19,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import vip.mate.workspace.core.annotation.RequireGlobalAdmin; +import vip.mate.agent.runtime.dsh.DshRuntimeService; /** * Admin-only live runtime surface: the global view of every in-flight agent @@ -40,6 +41,7 @@ public class AgentRuntimeController { private final AuditEventService auditEventService; private final ConversationService conversationService; private final I18nService i18nService; + private final DshRuntimeService dshRuntimeService; @Operation(summary = "Snapshot of every in-flight agent turn") @GetMapping("/snapshot") @@ -49,6 +51,14 @@ public class AgentRuntimeController { return R.ok(aggregator.snapshot()); } + @Operation(summary = "DSH runtime availability and capability diagnostics") + @GetMapping("/dsh/diagnostics") + @RequireGlobalAdmin + public R> dshDiagnostics(Authentication auth) { + requireAdmin(auth); + return R.ok(dshRuntimeService.diagnostics()); + } + @Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint") @PostMapping("/runs/{conversationId}/stop") @RequireGlobalAdmin diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java new file mode 100644 index 00000000..554929b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventProjector.java @@ -0,0 +1,63 @@ +package vip.mate.agent.runtime; + +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Projects normalized runtime events onto the existing chat stream vocabulary. */ +public final class RuntimeEventProjector { + private RuntimeEventProjector() {} + + public static AgentService.StreamDelta project(RuntimeEvent event) { + if (event == null) return AgentService.StreamDelta.empty(); + Map data = new LinkedHashMap<>(event.data()); + data.putIfAbsent("runtimeSessionId", event.sessionId()); + data.putIfAbsent("runtimeSequence", event.sequence()); + return switch (event.type()) { + case RUNTIME_READY -> AgentService.StreamDelta.event("phase", + with(data, "phase", "runtime_ready")); + case ASSISTANT_DELTA -> new AgentService.StreamDelta( + text(event, data), null, null, null, false, false, null); + case THINKING_DELTA -> new AgentService.StreamDelta( + null, text(event, data), null, null, false, false, null); + case TOOL_STARTED -> AgentService.StreamDelta.event("tool_call_started", + rename(data, "toolName", "name", "callId", "toolCallId")); + case TOOL_APPROVAL_REQUIRED -> AgentService.StreamDelta.event("tool_approval_requested", + rename(data, "requestId", "pendingId", "toolName", "toolName")); + case TOOL_FINISHED -> AgentService.StreamDelta.event("tool_call_completed", + rename(data, "callId", "toolCallId", "toolName", "toolName")); + case SUBAGENT_STARTED -> AgentService.StreamDelta.event("subagent_start", data); + case SUBAGENT_FINISHED -> AgentService.StreamDelta.event("subagent_complete", data); + case CONTEXT_USAGE -> AgentService.StreamDelta.event("_usage_final", data); + case COMPLETED -> AgentService.StreamDelta.event("done", data); + case FAILED -> AgentService.StreamDelta.event("error", data); + case CANCELLED -> AgentService.StreamDelta.event("cancelled", data); + }; + } + + private static Map with(Map source, String key, Object value) { + Map result = new LinkedHashMap<>(source); + result.put(key, value); + return result; + } + + private static String text(RuntimeEvent event, Map data) { + Object delta = data.get("delta"); + return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text(); + } + + private static Map rename(Map source, String from, String to, + String secondFrom, String secondTo) { + Map result = new LinkedHashMap<>(source); + copyIfPresent(result, from, to); + copyIfPresent(result, secondFrom, secondTo); + return result; + } + + private static void copyIfPresent(Map data, String from, String to) { + if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java new file mode 100644 index 00000000..45f712fe --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeEventStreamAdapter.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime; + +import reactor.core.publisher.Flux; +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; + +/** Adapts provider event streams to the native chat stream contract. */ +public final class RuntimeEventStreamAdapter { + private RuntimeEventStreamAdapter() {} + + public static Flux adapt(Flux events) { + if (events == null) return Flux.empty(); + return events.map(RuntimeEventProjector::project); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java new file mode 100644 index 00000000..f00d1fd4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/RuntimeProviderConfiguration.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import vip.mate.agent.runtime.contract.AgentRuntimeCoordinator; +import vip.mate.agent.runtime.contract.AgentRuntimeProvider; +import vip.mate.agent.runtime.contract.RuntimeProviderRegistry; + +import java.util.List; + +/** Spring wiring for the runtime SPI. Native execution remains owned by AgentService. */ +@Configuration +public class RuntimeProviderConfiguration { + @Bean + RuntimeProviderRegistry runtimeProviderRegistry(List providers) { + return new RuntimeProviderRegistry(providers); + } + + @Bean + AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry, + ObjectMapper objectMapper) { + return new AgentRuntimeCoordinator(registry, objectMapper); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java new file mode 100644 index 00000000..c79594f6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeConnection.java @@ -0,0 +1,17 @@ +package vip.mate.agent.runtime.contract; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public interface AgentRuntimeConnection extends AutoCloseable { + Flux prompt(String message); + + Mono cancel(); + + Mono contextUsage(); + + @Override + default void close() { + cancel().block(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java new file mode 100644 index 00000000..e4a0fad9 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinator.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; + +/** Selects, validates, and starts the provider chosen by an employee. */ +public final class AgentRuntimeCoordinator { + private final RuntimeProviderRegistry providerRegistry; + private final RuntimeSessionFactory sessionFactory; + + public AgentRuntimeCoordinator(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) { + this.providerRegistry = providerRegistry; + this.sessionFactory = new RuntimeSessionFactory(providerRegistry, objectMapper); + } + + public AgentRuntimeConnection start(AgentEntity agent, String conversationId, String sessionId, + String modelName, Path workspaceRoot, Path workingDirectory) { + RuntimeSession session = sessionFactory.create(agent, conversationId, sessionId, + modelName, workspaceRoot, workingDirectory); + return providerRegistry.resolve(agent.getRuntimeType()).start(session); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java new file mode 100644 index 00000000..adff59cc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/AgentRuntimeProvider.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.contract; + +public interface AgentRuntimeProvider { + String type(); + + RuntimeValidation validate(RuntimeSession session); + + RuntimeCapabilities capabilities(); + + AgentRuntimeConnection start(RuntimeSession session); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java new file mode 100644 index 00000000..536e0ca6 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeCapabilities.java @@ -0,0 +1,8 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeCapabilities( + boolean supportsCancellation, + boolean supportsApprovals, + boolean supportsSubagents, + boolean supportsContextUsage +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java new file mode 100644 index 00000000..7bbc342d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeContextUsage.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeContextUsage(long inputTokens, long outputTokens, long contextWindow) { + public RuntimeContextUsage { + if (inputTokens < 0 || outputTokens < 0 || contextWindow < 0) { + throw new IllegalArgumentException("usage values must be non-negative"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java new file mode 100644 index 00000000..4db9e2b1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEvent.java @@ -0,0 +1,38 @@ +package vip.mate.agent.runtime.contract; + +import java.util.Map; + +public record RuntimeEvent( + String sessionId, + long sequence, + RuntimeEventType type, + String text, + Map data, + boolean terminal +) { + public RuntimeEvent { + if (sessionId == null || sessionId.isBlank()) { + throw new IllegalArgumentException("sessionId is required"); + } + if (sequence < 0) { + throw new IllegalArgumentException("sequence must be non-negative"); + } + if (type == null) { + throw new IllegalArgumentException("type is required"); + } + if (terminal != type.terminal()) { + throw new IllegalArgumentException("terminal flag does not match event type"); + } + data = data == null ? Map.of() : Map.copyOf(data); + } + + public static RuntimeEvent of(String sessionId, long sequence, RuntimeEventType type, + String text, Map data) { + return new RuntimeEvent(sessionId, sequence, type, text, data, false); + } + + public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type, + Map data) { + return new RuntimeEvent(sessionId, sequence, type, null, data, true); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java new file mode 100644 index 00000000..8321074e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventLog.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.contract; + +import java.util.ArrayList; +import java.util.List; + +public final class RuntimeEventLog { + private final String sessionId; + private final List events = new ArrayList<>(); + private boolean terminal; + private long lastSequence = -1; + + public RuntimeEventLog(String sessionId) { + if (sessionId == null || sessionId.isBlank()) { + throw new IllegalArgumentException("sessionId is required"); + } + this.sessionId = sessionId; + } + + public synchronized void append(RuntimeEvent event) { + if (!sessionId.equals(event.sessionId())) { + throw new IllegalArgumentException("event belongs to another session"); + } + if (event.sequence() <= lastSequence) { + throw new IllegalArgumentException("event sequence must increase"); + } + if (terminal) { + throw new IllegalStateException("terminal event already appended"); + } + events.add(event); + lastSequence = event.sequence(); + terminal = event.terminal(); + } + + public synchronized List snapshot() { + return List.copyOf(events); + } + + public synchronized boolean terminal() { + return terminal; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java new file mode 100644 index 00000000..3338b41a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeEventType.java @@ -0,0 +1,20 @@ +package vip.mate.agent.runtime.contract; + +public enum RuntimeEventType { + RUNTIME_READY, + ASSISTANT_DELTA, + THINKING_DELTA, + TOOL_STARTED, + TOOL_APPROVAL_REQUIRED, + TOOL_FINISHED, + SUBAGENT_STARTED, + SUBAGENT_FINISHED, + CONTEXT_USAGE, + COMPLETED, + FAILED, + CANCELLED; + + public boolean terminal() { + return this == COMPLETED || this == FAILED || this == CANCELLED; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java new file mode 100644 index 00000000..6cd1052c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistry.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.contract; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class RuntimeProviderRegistry { + public static final String DEFAULT_RUNTIME = "native"; + + private final Map providers; + + public RuntimeProviderRegistry(List providers) { + Map registered = new LinkedHashMap<>(); + for (AgentRuntimeProvider provider : providers == null ? List.of() : providers) { + if (provider == null || provider.type() == null || provider.type().isBlank()) { + throw new IllegalArgumentException("runtime provider type is required"); + } + String type = normalize(provider.type()); + if (registered.putIfAbsent(type, provider) != null) { + throw new IllegalArgumentException("duplicate runtime provider: " + type); + } + } + this.providers = Map.copyOf(registered); + } + + public AgentRuntimeProvider resolve(String requestedType) { + String type = requestedType == null || requestedType.isBlank() + ? DEFAULT_RUNTIME + : normalize(requestedType); + AgentRuntimeProvider provider = providers.get(type); + if (provider == null) { + throw new IllegalArgumentException("unknown runtime provider: " + type); + } + return provider; + } + + private static String normalize(String type) { + return type.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java new file mode 100644 index 00000000..0a437651 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeResult.java @@ -0,0 +1,24 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeResult(Status status, String answer, String errorCode, String errorMessage) { + public enum Status { COMPLETED, FAILED, CANCELLED } + + public RuntimeResult { + if (status == null) throw new IllegalArgumentException("status is required"); + if (status == Status.COMPLETED && (errorCode != null || errorMessage != null)) { + throw new IllegalArgumentException("completed result cannot contain an error"); + } + } + + public static RuntimeResult completed(String answer) { + return new RuntimeResult(Status.COMPLETED, answer, null, null); + } + + public static RuntimeResult failed(String code, String message) { + return new RuntimeResult(Status.FAILED, null, code, message); + } + + public static RuntimeResult cancelled() { + return new RuntimeResult(Status.CANCELLED, null, null, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java new file mode 100644 index 00000000..ec91ef84 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSession.java @@ -0,0 +1,20 @@ +package vip.mate.agent.runtime.contract; + +import java.nio.file.Path; +import java.util.Map; + +public record RuntimeSession( + String sessionId, + String conversationId, + Long agentId, + Long workspaceId, + String modelName, + Path workingDirectory, + Map configuration +) { + public RuntimeSession { + if (sessionId == null || sessionId.isBlank()) throw new IllegalArgumentException("sessionId is required"); + if (conversationId == null || conversationId.isBlank()) throw new IllegalArgumentException("conversationId is required"); + configuration = configuration == null ? Map.of() : Map.copyOf(configuration); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java new file mode 100644 index 00000000..6313a7b0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeSessionFactory.java @@ -0,0 +1,75 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import vip.mate.agent.model.AgentEntity; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Map; + +/** Builds and validates the runtime-neutral session boundary for an employee turn. */ +public final class RuntimeSessionFactory { + private static final TypeReference> CONFIG_TYPE = new TypeReference<>() {}; + + private final RuntimeProviderRegistry providerRegistry; + private final ObjectMapper objectMapper; + + public RuntimeSessionFactory(RuntimeProviderRegistry providerRegistry, ObjectMapper objectMapper) { + this.providerRegistry = providerRegistry; + this.objectMapper = objectMapper; + } + + public RuntimeSession create(AgentEntity agent, String conversationId, String sessionId, + String modelName, Path workspaceRoot, Path workingDirectory) { + if (agent == null) throw new IllegalArgumentException("agent is required"); + AgentRuntimeProvider provider = providerRegistry.resolve(agent.getRuntimeType()); + String runtimeType = agent.getRuntimeType() == null || agent.getRuntimeType().isBlank() + ? RuntimeProviderRegistry.DEFAULT_RUNTIME : agent.getRuntimeType().trim().toLowerCase(); + + Path normalizedRoot = normalize(workspaceRoot); + Path normalizedWorkingDirectory = normalize(workingDirectory); + if ("dsh".equals(runtimeType)) { + if (agent.getWorkspaceId() == null) { + throw new IllegalArgumentException("dsh runtime requires a workspace"); + } + if (normalizedRoot == null || normalizedWorkingDirectory == null + || !normalizedWorkingDirectory.startsWith(normalizedRoot)) { + throw new IllegalArgumentException("dsh working directory must stay inside workspace"); + } + } + + RuntimeSession session = new RuntimeSession(sessionId, conversationId, agent.getId(), + agent.getWorkspaceId(), modelName, normalizedWorkingDirectory, + parseConfig(agent.getRuntimeConfig())); + RuntimeValidation validation = provider.validate(session); + if (validation == null || !validation.valid()) { + String code = validation == null ? "runtime.invalid" : validation.code(); + String message = validation == null ? "runtime provider rejected session" : validation.message(); + throw new IllegalArgumentException(code + ": " + message); + } + return session; + } + + private Map parseConfig(String raw) { + if (raw == null || raw.isBlank()) return Map.of(); + try { + JsonNode node = objectMapper.readTree(raw); + if (node == null || !node.isObject()) { + throw new IllegalArgumentException("runtime config must be a JSON object"); + } + return objectMapper.convertValue(node, CONFIG_TYPE); + } catch (IOException | IllegalArgumentException e) { + if (e instanceof IllegalArgumentException iae + && "runtime config must be a JSON object".equals(iae.getMessage())) { + throw iae; + } + throw new IllegalArgumentException("runtime config must be valid JSON", e); + } + } + + private static Path normalize(Path path) { + return path == null ? null : path.toAbsolutePath().normalize(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java new file mode 100644 index 00000000..8b9bf269 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/contract/RuntimeValidation.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.contract; + +public record RuntimeValidation(boolean valid, String code, String message) { + public static RuntimeValidation success() { + return new RuntimeValidation(true, null, null); + } + + public static RuntimeValidation invalid(String code, String message) { + return new RuntimeValidation(false, code, message); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java new file mode 100644 index 00000000..ce395883 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBinaryResolver.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; +import java.util.Optional; + +@FunctionalInterface +public interface DshBinaryResolver { + Optional resolve(); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java new file mode 100644 index 00000000..9bba8e58 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeAuthenticator.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +public final class DshBridgeAuthenticator { + private final byte[] expectedToken; + + public DshBridgeAuthenticator(String expectedToken) { + if (expectedToken == null || expectedToken.isBlank()) { + throw new IllegalArgumentException("bridge token is required"); + } + this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); + } + + public boolean accepts(String providedToken) { + if (providedToken == null) return false; + return MessageDigest.isEqual(expectedToken, + providedToken.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java new file mode 100644 index 00000000..f6386e04 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeConnection.java @@ -0,0 +1,65 @@ +package vip.mate.agent.runtime.dsh; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; + +public final class DshBridgeConnection implements AutoCloseable { + private static final int MAX_LINE_BYTES = 1_048_576; + + private final BufferedReader reader; + private final BufferedWriter writer; + private final DshBridgeProtocol protocol; + private final DshBridgeAuthenticator authenticator; + private boolean authenticated; + + public DshBridgeConnection(InputStream input, OutputStream output, + DshBridgeProtocol protocol, + DshBridgeAuthenticator authenticator) { + this.reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + this.writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); + this.protocol = protocol; + this.authenticator = authenticator; + } + + public boolean authenticate(String token) { + authenticated = authenticator.accepts(token); + return authenticated; + } + + public DshBridgeMessage receive() throws IOException { + requireAuthenticated(); + String line = reader.readLine(); + if (line == null) throw new IOException("DSH bridge closed"); + if (line.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) { + throw new IOException("DSH bridge message exceeds size limit"); + } + return protocol.decode(line); + } + + public void send(DshBridgeMessage message) throws IOException { + requireAuthenticated(); + String encoded = protocol.encode(message); + if (encoded.getBytes(StandardCharsets.UTF_8).length > MAX_LINE_BYTES) { + throw new IOException("DSH bridge message exceeds size limit"); + } + writer.write(encoded); + writer.flush(); + } + + private void requireAuthenticated() throws IOException { + if (!authenticated) throw new IOException("DSH bridge authentication required"); + } + + @Override + public void close() throws IOException { + reader.close(); + writer.close(); + authenticated = false; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java new file mode 100644 index 00000000..953c934f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeEvents.java @@ -0,0 +1,45 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DshBridgeEvents { + private DshBridgeEvents() {} + + public static DshBridgeMessage ready(String sessionId) { + return DshBridgeMessage.notification("ready", Map.of("sessionId", require(sessionId, "sessionId"))); + } + + public static DshBridgeMessage toolCall(String callId, String toolName, Map arguments) { + Map params = new LinkedHashMap<>(); + params.put("toolName", require(toolName, "toolName")); + params.put("arguments", arguments == null ? Map.of() : Map.copyOf(arguments)); + return DshBridgeMessage.request(require(callId, "callId"), "tool/call", params); + } + + public static DshBridgeMessage approvalAsk(String requestId, String toolName, String reason) { + Map params = new LinkedHashMap<>(); + params.put("toolName", require(toolName, "toolName")); + params.put("reason", reason == null ? "" : reason); + return DshBridgeMessage.request(require(requestId, "requestId"), "approval/ask", params); + } + + public static DshBridgeMessage subagentLifecycle(String subagentId, String phase, + Map data) { + Map params = new LinkedHashMap<>(); + params.put("subagentId", require(subagentId, "subagentId")); + params.put("phase", require(phase, "phase")); + if (data != null) params.putAll(Map.copyOf(data)); + return DshBridgeMessage.notification("subagent/lifecycle", params); + } + + public static DshBridgeMessage toolCancel(String callId) { + return DshBridgeMessage.notification("tool/cancel", + Map.of("callId", require(callId, "callId"))); + } + + private static String require(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + return value; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java new file mode 100644 index 00000000..ec0dff25 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMessage.java @@ -0,0 +1,26 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.Map; + +public record DshBridgeMessage( + String id, + String method, + Map params, + Object result, + String errorCode, + String errorMessage +) { + public DshBridgeMessage { + if (method == null || method.isBlank()) throw new IllegalArgumentException("method is required"); + params = params == null ? Map.of() : Map.copyOf(params); + } + + public static DshBridgeMessage request(String id, String method, Map params) { + if (id == null || id.isBlank()) throw new IllegalArgumentException("request id is required"); + return new DshBridgeMessage(id, method, params, null, null, null); + } + + public static DshBridgeMessage notification(String method, Map params) { + return new DshBridgeMessage(null, method, params, null, null, null); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java new file mode 100644 index 00000000..1476aa29 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeMethods.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime.dsh; + +import java.util.Set; + +public final class DshBridgeMethods { + private static final Set SUPPORTED = Set.of( + "session/open", "session/prompt", "session/cancel", "policy/update", "context/usage", + "ready", "tool/call", "approval/ask", "subagent/lifecycle", "tool/cancel"); + + private DshBridgeMethods() {} + + public static boolean isSupported(String method) { + return method != null && SUPPORTED.contains(method); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java new file mode 100644 index 00000000..db22898b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeProtocol.java @@ -0,0 +1,33 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public final class DshBridgeProtocol { + private final ObjectMapper objectMapper; + + public DshBridgeProtocol(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public String encode(DshBridgeMessage message) { + try { + return objectMapper.writeValueAsString(message) + "\n"; + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Unable to encode DSH bridge message", e); + } + } + + public DshBridgeMessage decode(String line) { + if (line == null || line.isBlank()) throw new IllegalArgumentException("bridge message is empty"); + try { + return objectMapper.readValue(line.trim(), DshBridgeMessage.class); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Invalid DSH bridge message", e); + } + } + + public boolean isNotification(DshBridgeMessage message) { + return message.id() == null; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java new file mode 100644 index 00000000..85c487d3 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshBridgeRequests.java @@ -0,0 +1,56 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class DshBridgeRequests { + private DshBridgeRequests() {} + + public static DshBridgeMessage sessionOpen(RuntimeSession session, Map policy) { + Map params = new LinkedHashMap<>(); + params.put("sessionId", session.sessionId()); + params.put("conversationId", session.conversationId()); + putIfPresent(params, "agentId", session.agentId()); + putIfPresent(params, "workspaceId", session.workspaceId()); + putIfPresent(params, "model", session.modelName()); + putIfPresent(params, "cwd", session.workingDirectory() == null + ? null : session.workingDirectory().toString()); + params.putAll(session.configuration()); + if (policy != null) params.put("policy", Map.copyOf(policy)); + return DshBridgeMessage.request("open-" + session.sessionId(), "session/open", params); + } + + public static DshBridgeMessage prompt(String requestId, String message) { + require(requestId, "requestId"); + if (message == null) throw new IllegalArgumentException("message is required"); + return DshBridgeMessage.request(requestId, "session/prompt", Map.of("message", message)); + } + + public static DshBridgeMessage cancel(String requestId, String sessionId) { + require(requestId, "requestId"); + require(sessionId, "sessionId"); + return DshBridgeMessage.request(requestId, "session/cancel", Map.of("sessionId", sessionId)); + } + + public static DshBridgeMessage policyUpdate(String requestId, Map policy) { + require(requestId, "requestId"); + return DshBridgeMessage.request(requestId, "policy/update", + Map.of("policy", policy == null ? Map.of() : Map.copyOf(policy))); + } + + public static DshBridgeMessage contextUsage(String requestId, String sessionId) { + require(requestId, "requestId"); + require(sessionId, "sessionId"); + return DshBridgeMessage.request(requestId, "context/usage", Map.of("sessionId", sessionId)); + } + + private static void require(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " is required"); + } + + private static void putIfPresent(Map target, String key, Object value) { + if (value != null) target.put(key, value); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java new file mode 100644 index 00000000..fdda7fe4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshManagedProcess.java @@ -0,0 +1,66 @@ +package vip.mate.agent.runtime.dsh; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class DshManagedProcess implements AutoCloseable { + private final DshProcessHandle process; + private final String sessionId; + private final Path binary; + private final Path sessionHome; + private final String bridgeToken; + private final Runnable onClosed; + private final AtomicBoolean closed = new AtomicBoolean(); + + DshManagedProcess(DshProcessHandle process, String sessionId, Path binary, + Path sessionHome, String bridgeToken) { + this(process, sessionId, binary, sessionHome, bridgeToken, () -> { }); + } + + DshManagedProcess(DshProcessHandle process, String sessionId, Path binary, + Path sessionHome, String bridgeToken, Runnable onClosed) { + this.process = process; + this.sessionId = sessionId; + this.binary = binary; + this.sessionHome = sessionHome; + this.bridgeToken = bridgeToken; + this.onClosed = onClosed == null ? () -> { } : onClosed; + } + + public DshProcessDiagnostics diagnostics() { + return new DshProcessDiagnostics(sessionId, binary, sessionHome, + process.isAlive(), bridgeToken != null && !bridgeToken.isBlank()); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) return; + if (process.isAlive()) { + process.destroy(); + if (!process.awaitExit(1_000L) && process.isAlive()) { + process.destroyForcibly(); + process.awaitExit(1_000L); + } + } + deleteRecursively(sessionHome); + onClosed.run(); + } + + private static void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) return; + try (var paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException ignored) { + // Cleanup is best effort; the process is already stopped. + } + }); + } catch (IOException ignored) { + // Cleanup is best effort; diagnostics retain the path for operators. + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java new file mode 100644 index 00000000..06fdabaa --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessDiagnostics.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; + +public record DshProcessDiagnostics( + String sessionId, + Path binary, + Path sessionHome, + boolean alive, + boolean bridgeTokenRedacted +) {} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java new file mode 100644 index 00000000..25e2345a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessHandle.java @@ -0,0 +1,11 @@ +package vip.mate.agent.runtime.dsh; + +public interface DshProcessHandle { + boolean isAlive(); + + void destroy(); + + void destroyForcibly(); + + boolean awaitExit(long millis); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java new file mode 100644 index 00000000..be724595 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessLauncher.java @@ -0,0 +1,10 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Path; + +@FunctionalInterface +public interface DshProcessLauncher { + DshProcessHandle launch(Path binary, RuntimeSession session, Path sessionHome, String bridgeToken); +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java new file mode 100644 index 00000000..c87d61ac --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshProcessManager.java @@ -0,0 +1,70 @@ +package vip.mate.agent.runtime.dsh; + +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +public final class DshProcessManager { + private final DshBinaryResolver binaryResolver; + private final DshProcessLauncher launcher; + private final Map active = new ConcurrentHashMap<>(); + + public DshProcessManager(DshBinaryResolver binaryResolver, DshProcessLauncher launcher) { + this.binaryResolver = binaryResolver; + this.launcher = launcher; + } + + public DshManagedProcess start(RuntimeSession session) { + stop(session.sessionId()); + Path binary = binaryResolver.resolve() + .filter(Files::isExecutable) + .orElseThrow(() -> new IllegalStateException("DSH binary is unavailable")); + Path sessionHome; + try { + sessionHome = Files.createTempDirectory("mateclaw-dsh-" + safeSessionId(session.sessionId()) + "-"); + } catch (IOException e) { + throw new IllegalStateException("Unable to create DSH session home", e); + } + String bridgeToken = UUID.randomUUID().toString(); + try { + DshProcessHandle process = launcher.launch(binary, session, sessionHome, bridgeToken); + if (process == null) throw new IllegalStateException("DSH launcher returned no process"); + DshManagedProcess managed = new DshManagedProcess(process, session.sessionId(), binary, + sessionHome, bridgeToken, () -> active.remove(session.sessionId())); + active.put(session.sessionId(), managed); + return managed; + } catch (RuntimeException e) { + deleteSessionHome(sessionHome); + throw e; + } + } + + public boolean stop(String sessionId) { + DshManagedProcess process = active.remove(sessionId); + if (process == null) return false; + process.close(); + return true; + } + + public Set activeSessionIds() { + return Set.copyOf(active.keySet()); + } + + private static String safeSessionId(String sessionId) { + return sessionId.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private static void deleteSessionHome(Path path) { + try (var paths = Files.walk(path)) { + paths.sorted(java.util.Comparator.reverseOrder()).forEach(candidate -> { + try { Files.deleteIfExists(candidate); } catch (IOException ignored) { } + }); + } catch (IOException ignored) { } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java new file mode 100644 index 00000000..a2b3d795 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshRuntimeService.java @@ -0,0 +1,494 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.runtime.RuntimeEventProjector; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; +import vip.mate.agent.runtime.contract.RuntimeSession; +import vip.mate.agent.runtime.contract.AgentRuntimeConnection; +import vip.mate.agent.runtime.contract.AgentRuntimeProvider; +import vip.mate.agent.runtime.contract.RuntimeCapabilities; +import vip.mate.agent.runtime.contract.RuntimeContextUsage; +import vip.mate.agent.runtime.contract.RuntimeValidation; +import vip.mate.agent.AgentService; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.llm.service.ModelProviderService; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Adapter for the official DeepSeek Harness SDK JSON-RPC runtime. + * + *

The runtime is intentionally an external process. This keeps the Node + * plugin graph out of the Spring classpath and lets deployments pin the DSH + * runtime independently from MateClaw.

+ */ +@Service +@Slf4j +public class DshRuntimeService implements AgentRuntimeProvider { + private final ObjectMapper objectMapper; + private final ModelConfigService modelConfigService; + private final ModelProviderService modelProviderService; + private final String runtimeCommand; + private final String cordisConfig; + + public DshRuntimeService( + ObjectMapper objectMapper, + ModelConfigService modelConfigService, + ModelProviderService modelProviderService, + @Value("${mateclaw.agent.runtime.dsh.command:}") String configuredCommand, + @Value("${mateclaw.agent.runtime.dsh.cordis-config:}") String configuredCordisConfig) { + this.objectMapper = objectMapper; + this.modelConfigService = modelConfigService; + this.modelProviderService = modelProviderService; + this.runtimeCommand = configuredCommand == null || configuredCommand.isBlank() + ? System.getenv().getOrDefault("DSH_JSONRPC_AGENT", "dsh-jsonrpc-agent") + : configuredCommand.trim(); + this.cordisConfig = resolveCordisConfig(configuredCordisConfig == null || configuredCordisConfig.isBlank() + ? System.getenv().getOrDefault("DSH_CORDIS_CONFIG", "") + : configuredCordisConfig.trim()); + log.info("[DSH] runtime configured: command={}, cordisConfig={}", runtimeCommand, + cordisConfig.isBlank() ? "" : cordisConfig); + } + + private String resolveCordisConfig(String configuredPath) { + if (configuredPath == null || configuredPath.isBlank()) return ""; + Path path = Path.of(configuredPath).toAbsolutePath().normalize(); + if (Files.isRegularFile(path)) return path.toString(); + // The documented source checkout path points at the package directory; + // the checked-in composition lives below its runtime subdirectory. + Path packageDirectory = Files.isDirectory(path) ? path : path.getParent(); + Path packagedConfig = packageDirectory == null + ? path + : packageDirectory.resolve("runtime").resolve("cordis.yml"); + return Files.isRegularFile(packagedConfig) ? packagedConfig.toString() : path.toString(); + } + + @Override + public String type() { + return "dsh"; + } + + @Override + public RuntimeValidation validate(RuntimeSession session) { + if (session == null || session.workspaceId() == null) { + return RuntimeValidation.invalid("dsh.workspace_required", "DSH runtime requires a workspace"); + } + if (session.workingDirectory() == null || !Files.isDirectory(session.workingDirectory())) { + return RuntimeValidation.invalid("dsh.working_directory_unavailable", "DSH working directory is unavailable"); + } + if (runtimeCommand.isBlank()) { + return RuntimeValidation.invalid("dsh.command_missing", "DSH runtime command is not configured"); + } + Path executable = Path.of(commandLine().get(0)); + if (!executable.isAbsolute() || !Files.isExecutable(executable)) { + return RuntimeValidation.invalid("dsh.command_unavailable", "DSH runtime command is not executable"); + } + if (!cordisConfig.isBlank() && !Files.isRegularFile(Path.of(cordisConfig))) { + return RuntimeValidation.invalid("dsh.cordis_missing", "DSH Cordis configuration is unavailable"); + } + return RuntimeValidation.success(); + } + + @Override + public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, false, true, true); + } + + public Map diagnostics() { + Path executable = runtimeCommand.isBlank() ? null : Path.of(commandLine().get(0)); + return Map.of( + "type", type(), + "commandConfigured", !runtimeCommand.isBlank(), + "command", runtimeCommand, + "executable", executable == null ? "" : executable.toString(), + "executableAvailable", executable != null && Files.isExecutable(executable), + "cordisConfig", cordisConfig, + "cordisConfigAvailable", !cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig)), + "capabilities", Map.of( + "cancellation", true, + "approvals", false, + "subagents", true, + "contextUsage", true)); + } + + public void validateAgentConfiguration(AgentEntity agent) { + if (agent == null || agent.getWorkspaceId() == null) { + throw new IllegalArgumentException("dsh.workspace_required: DSH runtime requires a workspace"); + } + if (agent.getRuntimeConfig() != null && !agent.getRuntimeConfig().isBlank()) { + try { + JsonNode node = objectMapper.readTree(agent.getRuntimeConfig()); + if (node == null || !node.isObject()) throw new IllegalArgumentException(); + } catch (Exception error) { + throw new IllegalArgumentException("dsh.runtime_config_invalid: runtime config must be a JSON object", error); + } + } + } + + @Override + public AgentRuntimeConnection start(RuntimeSession session) { + RuntimeValidation validation = validate(session); + if (!validation.valid()) { + throw new IllegalArgumentException(validation.code() + ": " + validation.message()); + } + AgentEntity agent = new AgentEntity(); + agent.setId(session.agentId()); + agent.setWorkspaceId(session.workspaceId()); + agent.setModelName(session.modelName()); + return new AgentRuntimeConnection() { + @Override + public Flux prompt(String message) { + return stream(agent, message, session.conversationId(), session.modelName()) + .map(DshRuntimeService.this::toRuntimeEvent); + } + + @Override + public reactor.core.publisher.Mono cancel() { + return reactor.core.publisher.Mono.empty(); + } + + @Override + public reactor.core.publisher.Mono contextUsage() { + return reactor.core.publisher.Mono.just(new RuntimeContextUsage(0, 0, 0)); + } + }; + } + + private RuntimeEvent toRuntimeEvent(AgentService.StreamDelta delta) { + if (delta == null) return RuntimeEvent.of("dsh", 0, RuntimeEventType.RUNTIME_READY, null, Map.of()); + if (delta.content() != null) { + return RuntimeEvent.of("dsh", 0, RuntimeEventType.ASSISTANT_DELTA, delta.content(), Map.of()); + } + if (delta.thinking() != null) { + return RuntimeEvent.of("dsh", 0, RuntimeEventType.THINKING_DELTA, delta.thinking(), Map.of()); + } + RuntimeEventType type = switch (delta.eventType() == null ? "" : delta.eventType()) { + case "done" -> RuntimeEventType.COMPLETED; + case "error" -> RuntimeEventType.FAILED; + case "cancelled" -> RuntimeEventType.CANCELLED; + case "tool_call_started" -> RuntimeEventType.TOOL_STARTED; + case "tool_call_completed" -> RuntimeEventType.TOOL_FINISHED; + case "tool_approval_requested" -> RuntimeEventType.TOOL_APPROVAL_REQUIRED; + default -> RuntimeEventType.RUNTIME_READY; + }; + return type.terminal() + ? RuntimeEvent.terminal("dsh", 0, type, delta.eventData()) + : RuntimeEvent.of("dsh", 0, type, null, delta.eventData()); + } + + public Flux stream(AgentEntity agent, String message, + String conversationId, String modelName) { + return Flux.create(sink -> { + Process process = null; + try { + RuntimeSession session = new RuntimeSession( + conversationId, + conversationId, + agent.getId(), + agent.getWorkspaceId(), + modelName, + Path.of(System.getenv().getOrDefault("DSH_CWD", System.getProperty("user.dir"))), + Map.of()); + Files.createDirectories(session.workingDirectory()); + ModelProviderEntity provider = resolveProvider(modelName); + String effectiveModelName = resolveModelName(modelName); + log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}", + modelName == null || modelName.isBlank() ? "" : modelName, + effectiveModelName, + provider == null ? "" : provider.getProviderId(), + provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(), + provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()); + List command = commandLine(); + ProcessBuilder builder = new ProcessBuilder(command) + .directory(session.workingDirectory().toFile()) + .redirectError(ProcessBuilder.Redirect.PIPE); + builder.environment().put("DSH_CWD", session.workingDirectory().toString()); + // The packaged binary gives the environment variable precedence + // over argv. Set the resolved path explicitly so IDEA/.env + // inheritance cannot select a different composition. + if (!cordisConfig.isBlank()) { + builder.environment().put("DSH_CORDIS_CONFIG", cordisConfig); + } else { + builder.environment().remove("DSH_CORDIS_CONFIG"); + } + log.debug("[DSH] child environment: cordisConfig={}, exists={}", + builder.environment().getOrDefault("DSH_CORDIS_CONFIG", ""), + !cordisConfig.isBlank() && Files.isRegularFile(Path.of(cordisConfig))); + if (provider != null) { + if (provider.getApiKey() != null && !provider.getApiKey().isBlank()) { + builder.environment().put("DEEPSEEK_API_KEY", provider.getApiKey()); + } + if (provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank()) { + builder.environment().put("DEEPSEEK_BASE_URL", provider.getBaseUrl()); + } + } + process = builder.start(); + Process activeProcess = process; + Thread stderrLogger = new Thread(() -> logProcessStderr(activeProcess), + "dsh-runtime-stderr-" + conversationId); + stderrLogger.setDaemon(true); + stderrLogger.start(); + sink.onCancel(() -> activeProcess.destroyForcibly()); + try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( + activeProcess.getOutputStream(), StandardCharsets.UTF_8)); + BufferedReader reader = new BufferedReader(new InputStreamReader( + activeProcess.getInputStream(), StandardCharsets.UTF_8))) { + send(writer, request("initialize", "init-" + conversationId, Map.of( + "cwd", session.workingDirectory().toString(), + "provider", "deepseek-official", + "model", effectiveModelName))); + awaitResponse(reader, "init-" + conversationId); + long sequence = 0; + sink.next(RuntimeEventProjector.project(RuntimeEvent.of( + conversationId, sequence++, RuntimeEventType.RUNTIME_READY, null, + Map.of("runtimeProvider", "dsh", "runtimeCommand", runtimeCommand)))); + + String promptId = "prompt-" + conversationId; + send(writer, request("session/prompt", promptId, Map.of( + "sessionId", conversationId, + "contentBlocks", List.of(Map.of("type", "text", "text", message))))); + + // DSH may emit session events before the JSON-RPC response + // for session/prompt. Read both on the same loop so those + // notifications are not discarded while waiting for id. + boolean terminal = false; + boolean promptResponseReceived = false; + String line; + while (!terminal && (line = reader.readLine()) != null) { + JsonNode payload = objectMapper.readTree(line); + if (payload == null) continue; + if (payload.has("id") && promptId.equals(payload.path("id").asText(null))) { + promptResponseReceived = true; + log.debug("[DSH] prompt response received: id={}, error={}", promptId, + payload.has("error")); + if (payload.has("error")) { + throw new IllegalStateException(payload.path("error").path("message") + .asText("DSH prompt failed")); + } + continue; + } + if (!payload.has("method")) continue; + String method = payload.path("method").asText(); + JsonNode params = payload.path("params"); + if (payload.has("id")) { + send(writer, errorResponse(payload.get("id"), -32601, "MateClaw does not support runtime request: " + method)); + continue; + } + if ("session.event".equals(method)) { + JsonNode event = params.path("event"); + log.debug("[DSH] event: type={}", event.path("type").asText("")); + logChunkMetadata(event); + logTerminalReason(event); + RuntimeEvent mapped = mapEvent(conversationId, sequence++, event); + if (mapped != null) { + sink.next(RuntimeEventProjector.project(mapped)); + terminal = mapped.terminal(); + } + } else if ("session.status".equals(method) + && promptResponseReceived + && "idle".equals(params.path("status").asText())) { + log.debug("[DSH] session idle after prompt"); + sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal( + conversationId, sequence++, RuntimeEventType.COMPLETED, Map.of()))); + terminal = true; + } + } + if (!terminal) { + int exitCode = activeProcess.waitFor(); + sink.next(RuntimeEventProjector.project(RuntimeEvent.terminal( + conversationId, sequence, RuntimeEventType.FAILED, + Map.of("error", "DSH runtime closed before completion (exit=" + exitCode + ")")))); + } + sink.complete(); + } + } catch (Exception error) { + sink.error(new IllegalStateException("DSH runtime unavailable: " + error.getMessage(), error)); + if (process != null) process.destroyForcibly(); + } + }); + } + + private void logProcessStderr(Process process) { + try (BufferedReader errors = new BufferedReader(new InputStreamReader( + process.getErrorStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = errors.readLine()) != null) { + log.warn("[DSH] {}", line); + } + } catch (IOException error) { + log.debug("[DSH] stderr reader closed: {}", error.getMessage()); + } + } + + private List commandLine() { + String[] parts = runtimeCommand.trim().split("\\s+"); + List result = new ArrayList<>(); + for (String part : parts) if (!part.isBlank()) result.add(part); + if (result.isEmpty()) throw new IllegalStateException("DSH runtime command is empty"); + log.debug("[DSH] launching command: {}", result); + return result; + } + + private ModelProviderEntity resolveProvider(String modelName) { + ModelConfigEntity model = null; + try { + model = modelConfigService.resolveModel(modelName); + } catch (RuntimeException ignored) { + // Fall back to the dedicated DeepSeek provider below. + } + if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) { + try { + return modelProviderService.getProviderConfig(model.getProvider()); + } catch (RuntimeException ignored) { + // The model row may outlive its provider row; use the runtime default. + } + } + try { + return modelProviderService.getProviderConfig("deepseek"); + } catch (RuntimeException ignored) { + return null; + } + } + + private String resolveModelName(String modelName) { + try { + ModelConfigEntity model = modelConfigService.resolveModel(modelName); + if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) { + return model.getModelName(); + } + } catch (RuntimeException ignored) { + // Fall back to the DSH catalog default for a not-yet-configured agent. + } + return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName; + } + + private RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) { + String type = event.path("type").asText(""); + JsonNode data = event.path("data"); + if ("assistant/chunk".equals(type)) { + JsonNode chunk = data.has("chunk") ? data.path("chunk") : data; + String text = firstText(chunk, data); + if (text != null && !text.isEmpty()) { + RuntimeEventType eventType = "reasoning-delta".equals(chunk.path("type").asText()) + ? RuntimeEventType.THINKING_DELTA + : RuntimeEventType.ASSISTANT_DELTA; + return RuntimeEvent.of(sessionId, sequence, eventType, text, + Map.of("chunkType", chunk.path("type").asText("unknown"))); + } + if ("finish".equals(chunk.path("type").asText()) + && "error".equals(chunk.path("reason").path("kind").asText())) { + JsonNode failure = chunk.path("reason").path("failure"); + return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED, + Map.of("error", failure.path("message").asText("DSH assistant failed"), + "code", failure.path("code").asText("DSH_RUNTIME_ERROR"))); + } + } + // The DSH stream emits text-delta chunks followed by an assistant/message + // snapshot. Mapping both would append the same answer twice to the UI. + if ("text-delta".equals(type)) { + String text = firstText(data, event); + if (text != null && !text.isEmpty()) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.ASSISTANT_DELTA, text, Map.of()); + } + } + if (type.contains("tool") && (type.contains("start") || type.contains("call"))) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_STARTED, null, + Map.of("toolName", data.path("toolName").asText("dsh-tool"))); + } + if (type.contains("tool") && (type.contains("end") || type.contains("result"))) { + return RuntimeEvent.of(sessionId, sequence, RuntimeEventType.TOOL_FINISHED, null, Map.of()); + } + if ("turn/end".equals(type)) { + String kind = data.path("reason").path("kind").asText(""); + if ("error".equals(kind)) { + return RuntimeEvent.terminal(sessionId, sequence, RuntimeEventType.FAILED, + Map.of("error", data.path("reason").path("error").path("message").asText("DSH turn failed"))); + } + } + return null; + } + + private String firstText(JsonNode primary, JsonNode fallback) { + String text = primary.path("text").asText(null); + if (text != null) return text; + text = primary.path("delta").path("text").asText(null); + if (text != null) return text; + text = fallback.path("text").asText(null); + if (text != null) return text; + return fallback.path("delta").path("text").asText(null); + } + + private void logChunkMetadata(JsonNode event) { + if (!"assistant/chunk".equals(event.path("type").asText())) return; + JsonNode data = event.path("data"); + JsonNode chunk = data.has("chunk") ? data.path("chunk") : data; + log.debug("[DSH] assistant chunk: type={}, fields={}, dataFields={}, textPresent={}, textLength={}", + chunk.path("type").asText(""), + chunk.fieldNames().hasNext(), data.fieldNames().hasNext(), + chunk.has("text"), chunk.path("text").isTextual() ? chunk.path("text").textValue().length() : 0); + } + + private void logTerminalReason(JsonNode event) { + String type = event.path("type").asText(""); + if (!"assistant/chunk".equals(type) && !"turn/end".equals(type)) return; + JsonNode reason = "assistant/chunk".equals(type) + ? event.path("data").path("chunk").path("reason") + : event.path("data").path("reason"); + if (reason.isMissingNode() || reason.isNull()) return; + JsonNode failure = reason.path("failure").isMissingNode() + ? reason.path("error") : reason.path("failure"); + log.warn("[DSH] terminal reason: eventType={}, kind={}, code={}, message={}", + type, + reason.path("kind").asText(""), + failure.path("code").asText(""), + failure.path("message").asText("")); + } + + private void awaitResponse(BufferedReader reader, String id) throws IOException { + String line; + while ((line = reader.readLine()) != null) { + JsonNode payload = objectMapper.readTree(line); + if (payload != null && id.equals(payload.path("id").asText(null))) { + if (payload.has("error")) { + throw new IllegalStateException(payload.path("error").path("message").asText("DSH JSON-RPC error")); + } + return; + } + } + throw new IOException("DSH runtime closed while waiting for " + id); + } + + private Map request(String method, String id, Map params) { + return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params); + } + + private Map errorResponse(JsonNode id, int code, String message) { + return Map.of("jsonrpc", "2.0", "id", objectMapper.convertValue(id, Object.class), + "error", Map.of("code", code, "message", message)); + } + + private void send(BufferedWriter writer, Map payload) throws IOException { + writer.write(objectMapper.writeValueAsString(payload)); + writer.newLine(); + writer.flush(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java new file mode 100644 index 00000000..ef90980a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolCatalog.java @@ -0,0 +1,22 @@ +package vip.mate.agent.runtime.dsh; + +import org.springframework.ai.tool.ToolCallback; + +import java.util.LinkedHashMap; +import java.util.List; + +public final class DshToolCatalog { + private DshToolCatalog() {} + + public static List fromCallbacks(List callbacks) { + LinkedHashMap descriptors = new LinkedHashMap<>(); + if (callbacks == null) return List.of(); + for (ToolCallback callback : callbacks) { + if (callback == null || callback.getToolDefinition() == null) continue; + var definition = callback.getToolDefinition(); + descriptors.putIfAbsent(definition.name(), new DshToolDescriptor( + definition.name(), definition.description(), definition.inputSchema())); + } + return List.copyOf(descriptors.values()); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java new file mode 100644 index 00000000..1f91ad0f --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDecision.java @@ -0,0 +1,7 @@ +package vip.mate.agent.runtime.dsh; + +public enum DshToolDecision { + ALLOW, + APPROVAL, + DENY +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java new file mode 100644 index 00000000..cf645409 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDescriptor.java @@ -0,0 +1,9 @@ +package vip.mate.agent.runtime.dsh; + +public record DshToolDescriptor(String name, String description, String inputSchema) { + public DshToolDescriptor { + if (name == null || name.isBlank()) throw new IllegalArgumentException("tool name is required"); + description = description == null ? "" : description; + inputSchema = inputSchema == null || inputSchema.isBlank() ? "{}" : inputSchema; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java new file mode 100644 index 00000000..0b5ff7dc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatchResult.java @@ -0,0 +1,15 @@ +package vip.mate.agent.runtime.dsh; + +public record DshToolDispatchResult(DshToolDecision decision, String output, String error) { + public static DshToolDispatchResult allowed(String output) { + return new DshToolDispatchResult(DshToolDecision.ALLOW, output, null); + } + + public static DshToolDispatchResult denied(String error) { + return new DshToolDispatchResult(DshToolDecision.DENY, null, error); + } + + public static DshToolDispatchResult approval(String reason) { + return new DshToolDispatchResult(DshToolDecision.APPROVAL, null, reason); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java new file mode 100644 index 00000000..b2f35136 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolDispatcher.java @@ -0,0 +1,42 @@ +package vip.mate.agent.runtime.dsh; + +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class DshToolDispatcher { + private final Map callbacks; + private final DshToolPolicy policy; + private final DshToolPolicyEvaluator policyEvaluator; + + public DshToolDispatcher(List callbacks, DshToolPolicy policy, + DshToolPolicyEvaluator policyEvaluator) { + Map byName = new LinkedHashMap<>(); + if (callbacks != null) { + for (ToolCallback callback : callbacks) { + if (callback != null && callback.getToolDefinition() != null) { + byName.putIfAbsent(callback.getToolDefinition().name(), callback); + } + } + } + this.callbacks = Map.copyOf(byName); + this.policy = policy; + this.policyEvaluator = policyEvaluator; + } + + public DshToolDispatchResult dispatch(String toolName, String argumentsJson, Path targetPath) { + ToolCallback callback = callbacks.get(toolName); + if (callback == null) return DshToolDispatchResult.denied("unknown tool"); + DshToolDecision decision = policyEvaluator.decide(policy, toolName, targetPath); + if (decision == DshToolDecision.DENY) return DshToolDispatchResult.denied("tool denied by policy"); + if (decision == DshToolDecision.APPROVAL) return DshToolDispatchResult.approval("tool approval required"); + try { + return DshToolDispatchResult.allowed(callback.call(argumentsJson == null ? "{}" : argumentsJson)); + } catch (RuntimeException e) { + return DshToolDispatchResult.denied("tool execution failed"); + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java new file mode 100644 index 00000000..020c96d8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicy.java @@ -0,0 +1,21 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; +import java.util.Set; + +public record DshToolPolicy( + Path workspaceRoot, + String permissionMode, + Set disabledTools, + Set readTools, + Set editTools, + Set autoApprovedTools +) { + public DshToolPolicy { + permissionMode = permissionMode == null ? "read-only" : permissionMode; + disabledTools = disabledTools == null ? Set.of() : Set.copyOf(disabledTools); + readTools = readTools == null ? Set.of() : Set.copyOf(readTools); + editTools = editTools == null ? Set.of() : Set.copyOf(editTools); + autoApprovedTools = autoApprovedTools == null ? Set.of() : Set.copyOf(autoApprovedTools); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java new file mode 100644 index 00000000..ac6f2959 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluator.java @@ -0,0 +1,27 @@ +package vip.mate.agent.runtime.dsh; + +import java.nio.file.Path; + +public final class DshToolPolicyEvaluator { + public DshToolDecision decide(DshToolPolicy policy, String toolName, Path targetPath) { + if (policy == null || toolName == null || toolName.isBlank()) return DshToolDecision.DENY; + if (policy.disabledTools().contains(toolName)) return DshToolDecision.DENY; + if (targetPath != null && !withinWorkspace(policy.workspaceRoot(), targetPath)) { + return DshToolDecision.DENY; + } + boolean edit = policy.editTools().contains(toolName); + if (edit && "read-only".equalsIgnoreCase(policy.permissionMode())) { + return DshToolDecision.DENY; + } + if (policy.autoApprovedTools().contains(toolName)) return DshToolDecision.ALLOW; + if (policy.readTools().contains(toolName) && !edit) return DshToolDecision.ALLOW; + return DshToolDecision.APPROVAL; + } + + private boolean withinWorkspace(Path root, Path target) { + if (root == null) return false; + Path normalizedRoot = root.toAbsolutePath().normalize(); + Path normalizedTarget = target.toAbsolutePath().normalize(); + return normalizedTarget.startsWith(normalizedRoot); + } +} diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index e5fe0b8e..7665c408 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -131,6 +131,13 @@ springdoc: # MateClaw 自定义配置 mateclaw: + # DeepSeek Harness runtime. The executable and Cordis composition are kept + # outside the Spring classpath and can be supplied by environment variables. + agent: + runtime: + dsh: + command: ${DSH_JSONRPC_AGENT:} + cordis-config: ${DSH_CORDIS_CONFIG:} server: # Public base URL used to build absolute download links for tool-generated # files (e.g. https://mateclaw.example.com). Leave empty to fall back to the diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..17b7c987 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V186__agent_runtime_provider.sql @@ -0,0 +1,10 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_type VARCHAR(32) NOT NULL DEFAULT 'native'; + +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_config CLOB DEFAULT NULL; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..058695e0 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V186__agent_runtime_provider.sql @@ -0,0 +1,13 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_type VARCHAR(32) DEFAULT 'native'; + +ALTER TABLE mate_agent + ADD COLUMN IF NOT EXISTS runtime_config TEXT DEFAULT NULL; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; + +ALTER TABLE mate_agent + ALTER COLUMN runtime_type SET DEFAULT 'native'; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql new file mode 100644 index 00000000..a347e169 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V186__agent_runtime_provider.sql @@ -0,0 +1,22 @@ +-- Add a runtime provider selector without changing the legacy agent_type. +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND COLUMN_NAME = 'runtime_type'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_agent ADD COLUMN runtime_type VARCHAR(32) NOT NULL DEFAULT ''native'' AFTER agent_type', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND COLUMN_NAME = 'runtime_config'); +SET @s := IF(@c = 0, + 'ALTER TABLE mate_agent ADD COLUMN runtime_config TEXT NULL AFTER runtime_type', + 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +UPDATE mate_agent +SET runtime_type = 'native' +WHERE runtime_type IS NULL OR TRIM(runtime_type) = ''; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java index 1e5957c7..56fff5ea 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/AgentServiceUniquenessTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; /** @@ -93,6 +94,36 @@ class AgentServiceUniquenessTest { assertNotEquals(a.getId(), b.getId()); } + @Test + @DisplayName("createAgent 默认使用 native runtime,兼容旧 Agent") + void createDefaultsToNativeRuntime() { + AgentEntity created = agentService.createAgent(newAgent("Native-default", workspaceA)); + + assertEquals("native", created.getRuntimeType()); + assertEquals("native", agentService.getAgent(created.getId()).getRuntimeType()); + + created.setRuntimeConfig("{\"binary\":\"dsh\"}"); + agentService.updateAgent(created); + assertEquals("{\"binary\":\"dsh\"}", + agentService.getAgent(created.getId()).getRuntimeConfig()); + + created.setRuntimeConfig(null); + agentService.updateAgent(created); + assertNull(agentService.getAgent(created.getId()).getRuntimeConfig()); + } + + @Test + @DisplayName("createAgent 拒绝未注册的 runtime provider") + void createRejectsUnknownRuntimeProvider() { + AgentEntity agent = newAgent("Unknown-runtime", workspaceA); + agent.setRuntimeType("unknown-provider"); + + MateClawException ex = assertThrows(MateClawException.class, + () -> agentService.createAgent(agent)); + assertEquals(400, ex.getCode()); + assertEquals("err.agent.runtime_unsupported", ex.getMsgKey()); + } + @Test @DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)") void createRejectsBlankName() { diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java new file mode 100644 index 00000000..6c0795df --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventProjectorTest.java @@ -0,0 +1,47 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimeEventProjectorTest { + @Test + void projectsAssistantAndToolEventsToExistingStreamVocabulary() { + AgentService.StreamDelta assistant = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 4, RuntimeEventType.ASSISTANT_DELTA, null, + Map.of("delta", "hello"))); + assertEquals("hello", assistant.content()); + + AgentService.StreamDelta tool = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 5, RuntimeEventType.TOOL_STARTED, null, + Map.of("callId", "call-1", "toolName", "read_file"))); + assertEquals("tool_call_started", tool.eventType()); + assertEquals("call-1", tool.eventData().get("toolCallId")); + assertEquals("read_file", tool.eventData().get("toolName")); + assertEquals("session-1", tool.eventData().get("runtimeSessionId")); + } + + @Test + void usesRuntimeEventTextWhenDeltaFieldIsAbsent() { + AgentService.StreamDelta assistant = RuntimeEventProjector.project( + RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "from-text", Map.of())); + + assertEquals("from-text", assistant.content()); + } + + @Test + void projectsTerminalEventsWithoutChangingTerminalMeaning() { + AgentService.StreamDelta failed = RuntimeEventProjector.project( + RuntimeEvent.terminal("session-1", 9, RuntimeEventType.FAILED, + Map.of("message", "bridge closed"))); + assertEquals("error", failed.eventType()); + assertTrue(failed.eventData().containsKey("runtimeSequence")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java new file mode 100644 index 00000000..dffd5075 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/RuntimeEventStreamAdapterTest.java @@ -0,0 +1,25 @@ +package vip.mate.agent.runtime; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import vip.mate.agent.runtime.contract.RuntimeEvent; +import vip.mate.agent.runtime.contract.RuntimeEventType; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RuntimeEventStreamAdapterTest { + @Test + void adaptsProviderFluxInOrder() { + var deltas = RuntimeEventStreamAdapter.adapt(Flux.just( + RuntimeEvent.of("session", 0, RuntimeEventType.ASSISTANT_DELTA, + null, Map.of("delta", "a")), + RuntimeEvent.of("session", 1, RuntimeEventType.THINKING_DELTA, + null, Map.of("delta", "b")))).collectList().block(); + + assertEquals(2, deltas.size()); + assertEquals("a", deltas.get(0).content()); + assertEquals("b", deltas.get(1).thinking()); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java new file mode 100644 index 00000000..c2a0dd72 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/AgentRuntimeCoordinatorTest.java @@ -0,0 +1,34 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AgentRuntimeCoordinatorTest { + @Test + void startsOnlyAfterSessionValidation() { + AgentRuntimeConnection connection = mock(AgentRuntimeConnection.class); + AgentRuntimeProvider provider = mock(AgentRuntimeProvider.class); + when(provider.type()).thenReturn("dsh"); + when(provider.validate(org.mockito.ArgumentMatchers.any())).thenReturn(RuntimeValidation.success()); + when(provider.start(org.mockito.ArgumentMatchers.any())).thenReturn(connection); + + AgentRuntimeCoordinator coordinator = new AgentRuntimeCoordinator( + new RuntimeProviderRegistry(List.of(provider)), new ObjectMapper()); + AgentEntity agent = new AgentEntity(); + agent.setId(1L); + agent.setWorkspaceId(2L); + agent.setRuntimeType("dsh"); + agent.setRuntimeConfig("{}"); + + assertSame(connection, coordinator.start(agent, "conversation", "session", "model", + Path.of("/workspace"), Path.of("/workspace/agent"))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java new file mode 100644 index 00000000..85522e0e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeContractInvariantTest.java @@ -0,0 +1,40 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RuntimeContractInvariantTest { + + @Test + void terminalFlagMustMatchEventType() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeEvent("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "text", Map.of(), true)); + } + + @Test + void completedResultCannotContainError() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeResult(RuntimeResult.Status.COMPLETED, "answer", "error", "broken")); + } + + @Test + void sessionConfigurationIsImmutable() { + Map configuration = new HashMap<>(); + configuration.put("permissionMode", "read-only"); + RuntimeSession session = new RuntimeSession( + "session-1", "conversation-1", 1L, 2L, "model", Path.of("/workspace"), configuration); + + configuration.put("permissionMode", "danger-full-access"); + + assertTrue(session.configuration().get("permissionMode").equals("read-only")); + assertThrows(UnsupportedOperationException.class, + () -> session.configuration().put("new", true)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java new file mode 100644 index 00000000..4e9056f3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeEventLogTest.java @@ -0,0 +1,38 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeEventLogTest { + + @Test + void rejectsEventsAfterTerminalEvent() { + RuntimeEventLog log = new RuntimeEventLog("session-1"); + + log.append(RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "hello", Map.of())); + log.append(RuntimeEvent.terminal("session-1", 2, RuntimeEventType.COMPLETED, + Map.of("answer", "hello"))); + + assertThrows(IllegalStateException.class, + () -> log.append(RuntimeEvent.of("session-1", 3, RuntimeEventType.ASSISTANT_DELTA, + "late", Map.of()))); + } + + @Test + void rejectsOutOfOrderEventsAndWrongSession() { + RuntimeEventLog log = new RuntimeEventLog("session-1"); + log.append(RuntimeEvent.of("session-1", 2, RuntimeEventType.RUNTIME_READY, + null, Map.of())); + + assertThrows(IllegalArgumentException.class, + () -> log.append(RuntimeEvent.of("session-1", 1, RuntimeEventType.ASSISTANT_DELTA, + "late", Map.of()))); + assertThrows(IllegalArgumentException.class, + () -> log.append(RuntimeEvent.of("session-2", 3, RuntimeEventType.ASSISTANT_DELTA, + "wrong", Map.of()))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java new file mode 100644 index 00000000..4745b73f --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeProviderRegistryTest.java @@ -0,0 +1,57 @@ +package vip.mate.agent.runtime.contract; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeProviderRegistryTest { + + @Test + void blankRuntimeUsesNativeProvider() { + AgentRuntimeProvider nativeProvider = provider("native"); + RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of(nativeProvider, provider("dsh"))); + + assertEquals(nativeProvider, registry.resolve(null)); + assertEquals(nativeProvider, registry.resolve("")); + } + + @Test + void unknownRuntimeIsRejected() { + RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of(provider("native"))); + + assertThrows(IllegalArgumentException.class, () -> registry.resolve("acp")); + } + + @Test + void duplicateRuntimeTypesAreRejected() { + assertThrows(IllegalArgumentException.class, + () -> new RuntimeProviderRegistry(List.of(provider("dsh"), provider("dsh")))); + } + + private static AgentRuntimeProvider provider(String type) { + return new AgentRuntimeProvider() { + @Override + public String type() { + return type; + } + + @Override + public RuntimeValidation validate(RuntimeSession session) { + return RuntimeValidation.success(); + } + + @Override + public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, true, true, true); + } + + @Override + public AgentRuntimeConnection start(RuntimeSession session) { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java new file mode 100644 index 00000000..de6b45cb --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/contract/RuntimeSessionFactoryTest.java @@ -0,0 +1,77 @@ +package vip.mate.agent.runtime.contract; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.model.AgentEntity; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class RuntimeSessionFactoryTest { + + private final RuntimeProviderRegistry registry = new RuntimeProviderRegistry(List.of( + provider("native", RuntimeValidation.success()), + provider("dsh", RuntimeValidation.success()) + )); + private final RuntimeSessionFactory factory = new RuntimeSessionFactory( + registry, new ObjectMapper()); + + @Test + void createsWorkspaceBoundDshSessionFromPersistedConfig() { + AgentEntity agent = agent("dsh", "{\"binary\":\"deepseek\"}"); + + RuntimeSession session = factory.create(agent, "conversation-1", "session-1", + "model-a", Path.of("/workspace"), Path.of("/workspace/project")); + + assertEquals("session-1", session.sessionId()); + assertEquals("conversation-1", session.conversationId()); + assertEquals(Map.of("binary", "deepseek"), session.configuration()); + } + + @Test + void rejectsDshSessionOutsideWorkspace() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "{}"), "conversation-1", "session-1", "model-a", + Path.of("/workspace"), Path.of("/tmp/outside"))); + } + + @Test + void rejectsDshSessionWithoutWorkspace() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "{}"), "conversation-1", "session-1", "model-a", + null, Path.of("/workspace"))); + } + + @Test + void rejectsNonObjectRuntimeConfig() { + assertThrows(IllegalArgumentException.class, () -> factory.create( + agent("dsh", "[]"), "conversation-1", "session-1", "model-a", + Path.of("/workspace"), Path.of("/workspace"))); + } + + private static AgentEntity agent(String runtimeType, String runtimeConfig) { + AgentEntity agent = new AgentEntity(); + agent.setId(7L); + agent.setWorkspaceId(9L); + agent.setRuntimeType(runtimeType); + agent.setRuntimeConfig(runtimeConfig); + return agent; + } + + private static AgentRuntimeProvider provider(String type, RuntimeValidation validation) { + return new AgentRuntimeProvider() { + @Override public String type() { return type; } + @Override public RuntimeValidation validate(RuntimeSession session) { return validation; } + @Override public RuntimeCapabilities capabilities() { + return new RuntimeCapabilities(true, true, true, true); + } + @Override public AgentRuntimeConnection start(RuntimeSession session) { + throw new UnsupportedOperationException("test provider"); + } + }; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java new file mode 100644 index 00000000..44f89919 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeEventsTest.java @@ -0,0 +1,26 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DshBridgeEventsTest { + @Test + void createsReadyToolApprovalAndSubagentMessages() { + assertEquals("ready", DshBridgeEvents.ready("s-1").method()); + assertEquals("tool/call", DshBridgeEvents.toolCall("c-1", "read", Map.of("path", "a.txt")).method()); + assertEquals("approval/ask", DshBridgeEvents.approvalAsk("a-1", "bash", "run command").method()); + assertEquals("subagent/lifecycle", DshBridgeEvents.subagentLifecycle( + "child-1", "started", Map.of("goal", "inspect")).method()); + } + + @Test + void createsToolCancellationNotification() { + DshBridgeMessage message = DshBridgeEvents.toolCancel("c-1"); + + assertEquals("tool/cancel", message.method()); + assertEquals("c-1", message.params().get("callId")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java new file mode 100644 index 00000000..4ca25213 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeProtocolTest.java @@ -0,0 +1,72 @@ +package vip.mate.agent.runtime.dsh; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DshBridgeProtocolTest { + private final DshBridgeProtocol protocol = new DshBridgeProtocol(new ObjectMapper()); + + @Test + void requestRoundTripsAsJsonLine() { + DshBridgeMessage request = DshBridgeMessage.request( + "7", "session/open", Map.of("sessionId", "s-1")); + + DshBridgeMessage decoded = protocol.decode(protocol.encode(request)); + + assertEquals(request, decoded); + assertFalse(protocol.isNotification(decoded)); + } + + @Test + void notificationHasNoRequestId() { + DshBridgeMessage notification = DshBridgeMessage.notification( + "tool/cancel", Map.of("callId", "call-1")); + + assertTrue(protocol.isNotification(notification)); + assertEquals(notification, protocol.decode(protocol.encode(notification))); + } + + @Test + void tokenAuthenticatorAcceptsOnlyExactToken() { + DshBridgeAuthenticator authenticator = new DshBridgeAuthenticator("secret"); + + assertTrue(authenticator.accepts("secret")); + assertFalse(authenticator.accepts("Secret")); + assertFalse(authenticator.accepts(null)); + } + + @Test + void lineConnectionRequiresAuthentication() throws Exception { + DshBridgeMessage message = DshBridgeMessage.notification("ready", Map.of()); + ByteArrayInputStream input = new ByteArrayInputStream(protocol.encode(message) + .getBytes(StandardCharsets.UTF_8)); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + DshBridgeConnection connection = new DshBridgeConnection( + input, output, protocol, new DshBridgeAuthenticator("secret")); + + assertFalse(connection.authenticate("wrong")); + assertTrue(connection.authenticate("secret")); + assertEquals(message, connection.receive()); + connection.send(message); + assertEquals(protocol.encode(message), output.toString(StandardCharsets.UTF_8)); + connection.close(); + } + + @Test + void malformedMessagesAndUnknownMethodsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> protocol.decode("{}")); + assertThrows(IllegalArgumentException.class, () -> protocol.decode("not-json")); + assertFalse(DshBridgeMethods.isSupported("unknown/method")); + assertTrue(DshBridgeMethods.isSupported("session/prompt")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java new file mode 100644 index 00000000..c9e43ce5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshBridgeRequestsTest.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class DshBridgeRequestsTest { + @Test + void createsSessionOpenWithSessionContext() { + DshBridgeMessage message = DshBridgeRequests.sessionOpen(session(), Map.of("read", true)); + + assertEquals("session/open", message.method()); + assertEquals("session-1", message.params().get("sessionId")); + assertEquals("conversation-1", message.params().get("conversationId")); + assertEquals("read-only", message.params().get("permissionMode")); + } + + @Test + void createsPromptCancelPolicyAndUsageRequests() { + assertEquals("session/prompt", DshBridgeRequests.prompt("7", "hello").method()); + assertEquals("session/cancel", DshBridgeRequests.cancel("8", "session-1").method()); + assertEquals("policy/update", DshBridgeRequests.policyUpdate("9", Map.of("mode", "read-only")).method()); + assertEquals("context/usage", DshBridgeRequests.contextUsage("10", "session-1").method()); + } + + @Test + void rejectsBlankRequestIdentifiers() { + assertThrows(IllegalArgumentException.class, () -> DshBridgeRequests.prompt("", "hello")); + assertThrows(IllegalArgumentException.class, () -> DshBridgeRequests.cancel("1", "")); + } + + private static RuntimeSession session() { + return new RuntimeSession("session-1", "conversation-1", 1L, 2L, + "model", Path.of("/workspace"), Map.of("permissionMode", "read-only")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java new file mode 100644 index 00000000..9c9b0f20 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshProcessManagerTest.java @@ -0,0 +1,86 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import vip.mate.agent.runtime.contract.RuntimeSession; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DshProcessManagerTest { + + @Test + void missingBinaryPreventsProcessLaunch() { + AtomicBoolean launched = new AtomicBoolean(); + DshProcessManager manager = new DshProcessManager( + () -> Optional.empty(), + (binary, session, home, token) -> { + launched.set(true); + return new FakeProcess(); + }); + + assertThrows(IllegalStateException.class, () -> manager.start(session())); + assertFalse(launched.get()); + } + + @Test + void closeStopsProcessAndRemovesSessionHome() throws Exception { + Path binary = Files.createTempFile("dsh", "bin"); + assertTrue(binary.toFile().setExecutable(true)); + FakeProcess process = new FakeProcess(); + DshProcessManager manager = new DshProcessManager( + () -> Optional.of(binary), + (ignored, ignoredSession, home, ignoredToken) -> { + process.home = home; + return process; + }); + + DshManagedProcess managed = manager.start(session()); + Path home = managed.diagnostics().sessionHome(); + assertTrue(Files.exists(home)); + assertTrue(managed.diagnostics().bridgeTokenRedacted()); + assertTrue(manager.activeSessionIds().contains("session-1")); + assertTrue(manager.stop("session-1")); + assertFalse(manager.stop("session-1")); + + assertTrue(process.destroyed.get()); + assertFalse(Files.exists(home)); + assertFalse(manager.activeSessionIds().contains("session-1")); + } + + private static RuntimeSession session() { + return new RuntimeSession("session-1", "conversation-1", 1L, 2L, + "model", Path.of("/workspace"), Map.of()); + } + + private static final class FakeProcess implements DshProcessHandle { + private final AtomicBoolean destroyed = new AtomicBoolean(); + private Path home; + + @Override + public boolean isAlive() { + return !destroyed.get(); + } + + @Override + public void destroy() { + destroyed.set(true); + } + + @Override + public void destroyForcibly() { + destroyed.set(true); + } + + @Override + public boolean awaitExit(long millis) { + return destroyed.get(); + } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java new file mode 100644 index 00000000..8d7c7013 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolCatalogTest.java @@ -0,0 +1,34 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DshToolCatalogTest { + @Test + void projectsToolDefinitionsAndDeduplicatesByRuntimeName() { + ToolCallback first = callback("read", "read file", "{\"type\":\"object\"}"); + ToolCallback duplicate = callback("read", "duplicate", "{}"); + ToolCallback second = callback("search", "search files", "{}"); + + List descriptors = DshToolCatalog.fromCallbacks(List.of(first, duplicate, second)); + + assertEquals(2, descriptors.size()); + assertEquals("read", descriptors.get(0).name()); + assertEquals("read file", descriptors.get(0).description()); + assertEquals("search", descriptors.get(1).name()); + } + + private static ToolCallback callback(String name, String description, String schema) { + ToolCallback callback = mock(ToolCallback.class); + when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder() + .name(name).description(description).inputSchema(schema).build()); + return callback; + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java new file mode 100644 index 00000000..01d4699d --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolDispatcherTest.java @@ -0,0 +1,59 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.definition.ToolDefinition; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DshToolDispatcherTest { + @Test + void allowedCallRunsHostCallback() { + ToolCallback callback = callback("read"); + when(callback.call("{\"path\":\"a.txt\"}")).thenReturn("content"); + DshToolDispatcher dispatcher = dispatcher(callback, + new DshToolPolicy(Path.of("/workspace"), "read-only", SetOf.none(), + SetOf.of("read"), SetOf.none(), SetOf.none())); + + DshToolDispatchResult result = dispatcher.dispatch( + "read", "{\"path\":\"a.txt\"}", Path.of("/workspace/a.txt")); + + assertEquals(DshToolDecision.ALLOW, result.decision()); + assertEquals("content", result.output()); + } + + @Test + void deniedAndApprovalCallsDoNotRunCallback() { + ToolCallback callback = callback("edit"); + DshToolDispatcher dispatcher = dispatcher(callback, + new DshToolPolicy(Path.of("/workspace"), "read-only", SetOf.none(), + SetOf.none(), SetOf.of("edit"), SetOf.none())); + + assertEquals(DshToolDecision.DENY, + dispatcher.dispatch("edit", "{}", Path.of("/workspace/a.txt")).decision()); + assertEquals(DshToolDecision.DENY, + dispatcher.dispatch("edit", "{}", Path.of("/tmp/a.txt")).decision()); + } + + private static DshToolDispatcher dispatcher(ToolCallback callback, DshToolPolicy policy) { + return new DshToolDispatcher(List.of(callback), policy, new DshToolPolicyEvaluator()); + } + + private static ToolCallback callback(String name) { + ToolCallback callback = mock(ToolCallback.class); + when(callback.getToolDefinition()).thenReturn(ToolDefinition.builder() + .name(name).description(name).inputSchema("{}").build()); + return callback; + } + + private static final class SetOf { + static java.util.Set none() { return java.util.Set.of(); } + static java.util.Set of(String value) { return java.util.Set.of(value); } + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java new file mode 100644 index 00000000..ad6ae382 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/runtime/dsh/DshToolPolicyEvaluatorTest.java @@ -0,0 +1,41 @@ +package vip.mate.agent.runtime.dsh; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DshToolPolicyEvaluatorTest { + private final DshToolPolicyEvaluator evaluator = new DshToolPolicyEvaluator(); + private final DshToolPolicy policy = new DshToolPolicy( + Path.of("/workspace"), "read-only", Set.of("disabled"), + Set.of("read"), Set.of("edit"), Set.of("safe")); + + @Test + void disabledToolIsDeniedBeforeOtherRules() { + assertEquals(DshToolDecision.DENY, evaluator.decide(policy, "disabled", null)); + } + + @Test + void pathOutsideWorkspaceIsDenied() { + assertEquals(DshToolDecision.DENY, + evaluator.decide(policy, "read", Path.of("/tmp/outside.txt"))); + } + + @Test + void readOnlyModeDeniesEditTools() { + assertEquals(DshToolDecision.DENY, + evaluator.decide(policy, "edit", Path.of("/workspace/a.txt"))); + } + + @Test + void explicitApprovalIsReturnedForAllowedEditInWriteMode() { + DshToolPolicy writePolicy = new DshToolPolicy( + Path.of("/workspace"), "workspace-write", Set.of(), + Set.of("read"), Set.of("edit"), Set.of()); + assertEquals(DshToolDecision.APPROVAL, + evaluator.decide(writePolicy, "edit", Path.of("/workspace/a.txt"))); + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index b3c9a68d..f25584d6 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -439,6 +439,7 @@ export interface LiveSnapshot { export const liveApi = { snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'), + dshDiagnostics: () => http.get<{ data: Record }>('/admin/agent-runtime/dsh/diagnostics'), stop: (conversationId: string) => http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`), recycle: (conversationId: string) => diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 5f7474e6..77a9f9d1 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1572,6 +1572,7 @@ export default { all: 'All', react: 'ReAct', planExecute: 'Plan-Execute', + dsh: 'DSH Harness', enabled: 'Enabled', disabled: 'Disabled', basic: 'Basic', @@ -1605,6 +1606,13 @@ export default { name: 'Name', icon: 'Icon', type: 'Type', + runtime: 'Runtime', + runtimeConfig: 'DSH Configuration (JSON)', + runtimeNativeHint: 'Uses MateClaw\'s native Agent Loop.', + runtimeDshHint: 'Uses the DeepSeek Harness Loop; workspace and provider are checked at startup.', + runtimeConfigHint: 'JSON configuration consumed by the provider. Leave empty for provider defaults.', + runtimeReady: 'DSH runtime is ready (command and Cordis config are available).', + runtimeUnavailable: 'DSH runtime is unavailable. Check the command path and Cordis config.', description: 'Description', systemPrompt: 'System Prompt', role: 'Role', @@ -1683,6 +1691,11 @@ export default { deleteSuccess: 'Employee let go', toggleFailed: 'Failed to toggle status', toggleSuccess: 'Status updated', + runtimeConfigInvalid: 'DSH configuration must be a valid JSON object', + }, + runtime: { + native: 'Native Loop', + dsh: 'DSH Harness', }, tagFilter: { label: 'Tags', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 15802370..566fc967 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1429,6 +1429,7 @@ export default { all: '全部', react: 'ReAct', planExecute: 'Plan-Execute', + dsh: 'DSH Harness', enabled: '已启用', disabled: '已停用', basic: '基本信息', @@ -1462,6 +1463,13 @@ export default { name: '名称', icon: '图标', type: '类型', + runtime: '运行时', + runtimeConfig: 'DSH 配置(JSON)', + runtimeNativeHint: '使用 MateClaw 原生 Agent Loop。', + runtimeDshHint: '使用 DeepSeek Harness Loop;启动时会校验工作区和 provider。', + runtimeConfigHint: '填写 provider 需要的 JSON 配置;留空表示使用默认配置。', + runtimeReady: 'DSH 运行时已就绪(命令与 Cordis 配置可用)。', + runtimeUnavailable: 'DSH 运行时不可用,请检查命令路径和 Cordis 配置。', description: '描述', systemPrompt: '系统提示词', role: '岗位(Role)', @@ -1540,6 +1548,11 @@ export default { deleteSuccess: '员工已离职', toggleFailed: '切换状态失败', toggleSuccess: '状态已更新', + runtimeConfigInvalid: 'DSH 配置必须是合法的 JSON 对象', + }, + runtime: { + native: 'Native Loop', + dsh: 'DSH Harness', }, tagFilter: { label: '标签', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index ae56fcdf..9d9ec1f2 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -37,6 +37,8 @@ export interface Agent { name: string description?: string agentType: 'react' | 'plan_execute' + runtimeType?: 'native' | 'dsh' | string + runtimeConfig?: string | null systemPrompt?: string modelName?: string maxIterations: number diff --git a/mateclaw-ui/src/views/AgentCreateWizard.vue b/mateclaw-ui/src/views/AgentCreateWizard.vue index b395e06c..9043034e 100644 --- a/mateclaw-ui/src/views/AgentCreateWizard.vue +++ b/mateclaw-ui/src/views/AgentCreateWizard.vue @@ -99,6 +99,18 @@ +
+ + +
+
+ + + {{ t('agents.fields.runtimeConfigHint') }} +
@@ -234,6 +246,8 @@ interface Draft { icon: string description: string agentType: string + runtimeType: 'native' | 'dsh' + runtimeConfig: string | null systemPrompt: string role?: string goal?: string @@ -308,6 +322,8 @@ async function generate() { icon: d.icon || '🤖', description: d.description || '', agentType: d.agentType || 'react', + runtimeType: d.runtimeType === 'dsh' ? 'dsh' : 'native', + runtimeConfig: d.runtimeConfig || null, systemPrompt: d.systemPrompt || '', role: d.role, goal: d.goal, @@ -343,6 +359,8 @@ async function confirmCreate() { icon: draft.value.icon, description: draft.value.description, agentType: draft.value.agentType, + runtimeType: draft.value.runtimeType, + runtimeConfig: draft.value.runtimeType === 'dsh' ? (draft.value.runtimeConfig || '{}') : null, systemPrompt: draft.value.systemPrompt, tags, enabled: true, @@ -457,6 +475,8 @@ function goRoster() { .wiz-field.wiz-full { grid-column: 1 / -1; } .wiz-field > label { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin-bottom: 6px; } .wiz-field .req { color: var(--mc-primary); } +.wiz-field-hint { margin-top: 5px; color: var(--mc-text-tertiary); font-size: 12px; line-height: 1.45; } +.runtime-config-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; line-height: 1.5; } .wiz-control { width: 100%; box-sizing: border-box; padding: 9px 12px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-input-bg); font-size: 14px; color: var(--mc-text-primary); font-family: inherit; outline: none; } diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index 0d770165..f2080e70 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -114,6 +114,11 @@

{{ agentTagline(agent) || t('agents.messages.noTagline') }}

+ + + + {{ agent.runtimeType === 'dsh' ? t('agents.runtime.dsh') : t('agents.runtime.native') }} +
@@ -292,6 +297,24 @@
+
+ + +

{{ form.runtimeType === 'dsh' ? t('agents.fields.runtimeDshHint') : t('agents.fields.runtimeNativeHint') }}

+
+
+ + +

{{ t('agents.fields.runtimeConfigHint') }}

+

+ {{ dshDiagnostics.executableAvailable && dshDiagnostics.cordisConfigAvailable + ? t('agents.fields.runtimeReady') + : t('agents.fields.runtimeUnavailable') }} +

+
@@ -960,6 +983,7 @@ const selectedProviderPrefs = ref>([]) +const dshDiagnostics = ref | null>(null) // Template selector state const showTemplateSelector = ref(false) @@ -970,6 +994,7 @@ const filterTabs = [ { key: 'agents.tabs.all', value: 'all' }, { key: 'agents.tabs.react', value: 'react' }, { key: 'agents.tabs.planExecute', value: 'plan_execute' }, + { key: 'agents.tabs.dsh', value: 'dsh' }, { key: 'agents.tabs.enabled', value: 'enabled' }, { key: 'agents.tabs.disabled', value: 'disabled' }, ] @@ -978,6 +1003,8 @@ const defaultForm = (): Partial & { name: string; defaultThinkingLevel: s name: '', description: '', agentType: 'react', + runtimeType: 'native', + runtimeConfig: null, systemPrompt: '', modelName: '', // RFC-03 G1 — empty means "use global default" maxIterations: 10, @@ -1145,6 +1172,7 @@ const filteredAgents = computed(() => { } if (activeFilter.value === 'react') list = list.filter(a => a.agentType === 'react') else if (activeFilter.value === 'plan_execute') list = list.filter(a => a.agentType === 'plan_execute') + else if (activeFilter.value === 'dsh') list = list.filter(a => a.runtimeType === 'dsh') else if (activeFilter.value === 'enabled') list = list.filter(a => a.enabled) else if (activeFilter.value === 'disabled') list = list.filter(a => !a.enabled) // Tag filter: intersection — an agent must carry every selected tag. @@ -1194,8 +1222,18 @@ async function refreshLiveCounts() { } } +async function loadDshDiagnostics() { + try { + const res: any = await liveApi.dshDiagnostics() + dshDiagnostics.value = res?.data ?? res + } catch { + dshDiagnostics.value = null + } +} + onMounted(() => { loadAgents() + loadDshDiagnostics() // RFC-03 G1: load models once for the per-Agent override dropdown. // Failure is non-fatal — the dropdown just shows only "global default". loadAvailableModels() @@ -1342,6 +1380,8 @@ async function openEditModal(agent: Agent) { name: agent.name, description: agent.description || '', agentType: agent.agentType, + runtimeType: agent.runtimeType || 'native', + runtimeConfig: agent.runtimeConfig || null, systemPrompt: agent.systemPrompt || '', modelName: agent.modelName || '', maxIterations: agent.maxIterations, @@ -1440,6 +1480,18 @@ function closeModal() { async function saveAgent() { try { + if (form.value.runtimeType === 'dsh') { + try { + const parsed = JSON.parse(form.value.runtimeConfig || '{}') + if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('object') + form.value.runtimeConfig = JSON.stringify(parsed, null, 2) + } catch { + mcToast.error(t('agents.messages.runtimeConfigInvalid')) + return + } + } else { + form.value.runtimeConfig = null + } // Flatten the structured profile back to a single systemPrompt before // sending to the backend — the schema is unchanged, only the editor // exposes the H2 sections to the user. @@ -1740,6 +1792,30 @@ html.dark .seg-count.warn { letter-spacing: -0.005em; } +.agent-runtime-badge { + display: inline-flex; + align-items: center; + gap: 4px; + width: fit-content; + padding: 2px 7px; + border-radius: 999px; + background: var(--mc-bg-sunken); + color: var(--mc-text-tertiary); + font-size: 11px; + font-weight: 600; +} +.agent-runtime-badge--dsh { + background: var(--mc-primary-bg); + color: var(--mc-primary-hover); +} + +.runtime-config-editor { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + resize: vertical; +} + .agent-card__tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .agent-card__tag { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; cursor: pointer; transition: all 0.15s; } .agent-card__tag:hover { color: var(--mc-text-secondary); } diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 5c0e4ce3..49a5d4fc 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -63,6 +63,13 @@
{{ currentAgent.name }}
+ + {{ currentAgentRuntimeLabel }} +
@@ -873,6 +880,9 @@ const currentAgentRuntimeMode = computed(() => { return a.agentType === 'react' ? t('agents.types.react') : t('agents.types.planExecute') }) +const currentAgentRuntimeType = computed(() => currentAgent.value?.runtimeType === 'dsh' ? 'dsh' : 'native') +const currentAgentRuntimeLabel = computed(() => t(`agents.runtime.${currentAgentRuntimeType.value}`)) + // Per-conversation last-viewed timestamp store (localStorage-backed, MVP). // Keyed by conversationId. Updated when the user opens a conversation; the // sidebar reads it (ConversationSidebar.hasUnread) to render the accent dot. @@ -2651,6 +2661,26 @@ function handleCodeCopy(e: MouseEvent) { line-height: 1.2; } +.agent-runtime-badge { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + border: 1px solid var(--mc-border); + border-radius: 999px; + color: var(--mc-text-tertiary); + font-size: 10px; + font-weight: 600; + line-height: 1; + white-space: nowrap; +} + +.agent-runtime-badge--dsh { + border-color: rgba(24, 126, 104, 0.28); + color: #187e68; + background: rgba(24, 126, 104, 0.08); +} + .status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;