feat(agent): integrate DeepSeek Harness runtime

This commit is contained in:
matevip 2026-08-18 05:25:22 -04:00
parent 0eb5a1dd40
commit 473ed5786e
67 changed files with 2523 additions and 0 deletions

1
.gitignore vendored
View File

@ -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)

View File

@ -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<String> 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.

View File

@ -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;

View File

@ -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<Map<String, Object>> 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

View File

@ -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<String, Object> 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<String, Object> with(Map<String, Object> source, String key, Object value) {
Map<String, Object> result = new LinkedHashMap<>(source);
result.put(key, value);
return result;
}
private static String text(RuntimeEvent event, Map<String, Object> data) {
Object delta = data.get("delta");
return delta != null ? String.valueOf(delta) : event.text() == null ? "" : event.text();
}
private static Map<String, Object> rename(Map<String, Object> source, String from, String to,
String secondFrom, String secondTo) {
Map<String, Object> result = new LinkedHashMap<>(source);
copyIfPresent(result, from, to);
copyIfPresent(result, secondFrom, secondTo);
return result;
}
private static void copyIfPresent(Map<String, Object> data, String from, String to) {
if (!data.containsKey(to) && data.containsKey(from)) data.put(to, data.get(from));
}
}

View File

@ -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<AgentService.StreamDelta> adapt(Flux<RuntimeEvent> events) {
if (events == null) return Flux.empty();
return events.map(RuntimeEventProjector::project);
}
}

View File

@ -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<AgentRuntimeProvider> providers) {
return new RuntimeProviderRegistry(providers);
}
@Bean
AgentRuntimeCoordinator agentRuntimeCoordinator(RuntimeProviderRegistry registry,
ObjectMapper objectMapper) {
return new AgentRuntimeCoordinator(registry, objectMapper);
}
}

View File

@ -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<RuntimeEvent> prompt(String message);
Mono<Void> cancel();
Mono<RuntimeContextUsage> contextUsage();
@Override
default void close() {
cancel().block();
}
}

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package vip.mate.agent.runtime.contract;
public record RuntimeCapabilities(
boolean supportsCancellation,
boolean supportsApprovals,
boolean supportsSubagents,
boolean supportsContextUsage
) {}

View File

@ -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");
}
}
}

View File

@ -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<String, Object> 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<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, text, data, false);
}
public static RuntimeEvent terminal(String sessionId, long sequence, RuntimeEventType type,
Map<String, Object> data) {
return new RuntimeEvent(sessionId, sequence, type, null, data, true);
}
}

View File

@ -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<RuntimeEvent> 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<RuntimeEvent> snapshot() {
return List.copyOf(events);
}
public synchronized boolean terminal() {
return terminal;
}
}

View File

@ -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;
}
}

View File

@ -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<String, AgentRuntimeProvider> providers;
public RuntimeProviderRegistry(List<AgentRuntimeProvider> providers) {
Map<String, AgentRuntimeProvider> registered = new LinkedHashMap<>();
for (AgentRuntimeProvider provider : providers == null ? List.<AgentRuntimeProvider>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);
}
}

View File

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

View File

@ -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<String, Object> 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);
}
}

View File

@ -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<Map<String, Object>> 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<String, Object> 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();
}
}

View File

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

View File

@ -0,0 +1,9 @@
package vip.mate.agent.runtime.dsh;
import java.nio.file.Path;
import java.util.Optional;
@FunctionalInterface
public interface DshBinaryResolver {
Optional<Path> resolve();
}

View File

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

View File

@ -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;
}
}

View File

@ -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<String, Object> arguments) {
Map<String, Object> 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<String, Object> 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<String, Object> data) {
Map<String, Object> 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;
}
}

View File

@ -0,0 +1,26 @@
package vip.mate.agent.runtime.dsh;
import java.util.Map;
public record DshBridgeMessage(
String id,
String method,
Map<String, Object> 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<String, Object> 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<String, Object> params) {
return new DshBridgeMessage(null, method, params, null, null, null);
}
}

View File

@ -0,0 +1,15 @@
package vip.mate.agent.runtime.dsh;
import java.util.Set;
public final class DshBridgeMethods {
private static final Set<String> 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);
}
}

View File

@ -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;
}
}

View File

@ -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<String, Object> policy) {
Map<String, Object> 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<String, Object> 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<String, Object> target, String key, Object value) {
if (value != null) target.put(key, value);
}
}

View File

@ -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.
}
}
}

View File

@ -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
) {}

View File

@ -0,0 +1,11 @@
package vip.mate.agent.runtime.dsh;
public interface DshProcessHandle {
boolean isAlive();
void destroy();
void destroyForcibly();
boolean awaitExit(long millis);
}

View File

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

View File

@ -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<String, DshManagedProcess> 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<String> 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) { }
}
}

View File

@ -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.
*
* <p>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.</p>
*/
@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() ? "<empty>" : 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<String, Object> 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<RuntimeEvent> prompt(String message) {
return stream(agent, message, session.conversationId(), session.modelName())
.map(DshRuntimeService.this::toRuntimeEvent);
}
@Override
public reactor.core.publisher.Mono<Void> cancel() {
return reactor.core.publisher.Mono.empty();
}
@Override
public reactor.core.publisher.Mono<RuntimeContextUsage> 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<AgentService.StreamDelta> 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() ? "<default>" : modelName,
effectiveModelName,
provider == null ? "<missing>" : provider.getProviderId(),
provider != null && provider.getApiKey() != null && !provider.getApiKey().isBlank(),
provider != null && provider.getBaseUrl() != null && !provider.getBaseUrl().isBlank());
List<String> 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", "<empty>"),
!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("<missing>"));
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<String> commandLine() {
String[] parts = runtimeCommand.trim().split("\\s+");
List<String> 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("<missing>"),
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("<missing>"),
failure.path("code").asText("<none>"),
failure.path("message").asText("<none>"));
}
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<String, Object> request(String method, String id, Map<String, Object> params) {
return Map.of("jsonrpc", "2.0", "id", id, "method", method, "params", params);
}
private Map<String, Object> 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<String, Object> payload) throws IOException {
writer.write(objectMapper.writeValueAsString(payload));
writer.newLine();
writer.flush();
}
}

View File

@ -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<DshToolDescriptor> fromCallbacks(List<ToolCallback> callbacks) {
LinkedHashMap<String, DshToolDescriptor> 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());
}
}

View File

@ -0,0 +1,7 @@
package vip.mate.agent.runtime.dsh;
public enum DshToolDecision {
ALLOW,
APPROVAL,
DENY
}

View File

@ -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;
}
}

View File

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

View File

@ -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<String, ToolCallback> callbacks;
private final DshToolPolicy policy;
private final DshToolPolicyEvaluator policyEvaluator;
public DshToolDispatcher(List<ToolCallback> callbacks, DshToolPolicy policy,
DshToolPolicyEvaluator policyEvaluator) {
Map<String, ToolCallback> 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");
}
}
}

View File

@ -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<String> disabledTools,
Set<String> readTools,
Set<String> editTools,
Set<String> 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);
}
}

View File

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

View File

@ -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

View File

@ -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) = '';

View File

@ -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';

View File

@ -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) = '';

View File

@ -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() {

View File

@ -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"));
}
}

View File

@ -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());
}
}

View File

@ -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")));
}
}

View File

@ -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<String, Object> 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));
}
}

View File

@ -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())));
}
}

View File

@ -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();
}
};
}
}

View File

@ -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");
}
};
}
}

View File

@ -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"));
}
}

View File

@ -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"));
}
}

View File

@ -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"));
}
}

View File

@ -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();
}
}
}

View File

@ -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<DshToolDescriptor> 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;
}
}

View File

@ -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<String> none() { return java.util.Set.of(); }
static java.util.Set<String> of(String value) { return java.util.Set.of(value); }
}
}

View File

@ -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")));
}
}

View File

@ -439,6 +439,7 @@ export interface LiveSnapshot {
export const liveApi = {
snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'),
dshDiagnostics: () => http.get<{ data: Record<string, any> }>('/admin/agent-runtime/dsh/diagnostics'),
stop: (conversationId: string) =>
http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`),
recycle: (conversationId: string) =>

View File

@ -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',

View File

@ -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: '标签',

View File

@ -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

View File

@ -99,6 +99,18 @@
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
</select>
</div>
<div class="wiz-field">
<label>{{ t('agents.fields.runtime') }}</label>
<select v-model="draft.runtimeType" class="wiz-control">
<option value="native">{{ t('agents.runtime.native') }}</option>
<option value="dsh">{{ t('agents.runtime.dsh') }}</option>
</select>
</div>
<div v-if="draft.runtimeType === 'dsh'" class="wiz-field wiz-full">
<label>{{ t('agents.fields.runtimeConfig') }}</label>
<textarea v-model="draft.runtimeConfig" class="wiz-control runtime-config-editor" rows="4" spellcheck="false" placeholder="{}"></textarea>
<small class="wiz-field-hint">{{ t('agents.fields.runtimeConfigHint') }}</small>
</div>
<div class="wiz-field wiz-full">
<label>{{ t('agents.wizard.fields.description') }}</label>
<input v-model="draft.description" class="wiz-control" />
@ -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; }

View File

@ -114,6 +114,11 @@
<p class="agent-card__tagline">
{{ agentTagline(agent) || t('agents.messages.noTagline') }}
</p>
<span class="agent-runtime-badge" :class="{ 'agent-runtime-badge--dsh': agent.runtimeType === 'dsh' }">
<svg v-if="agent.runtimeType === 'dsh'" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 17l6-6 4 4 6-8"/><path d="M4 20h16"/></svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/></svg>
{{ agent.runtimeType === 'dsh' ? t('agents.runtime.dsh') : t('agents.runtime.native') }}
</span>
<div v-if="agentTags(agent).length" class="agent-card__tags">
<span v-for="tag in agentTags(agent)" :key="tag" class="agent-card__tag"
:class="{ active: activeTags.includes(tag) }" @click="toggleTag(tag)">
@ -292,6 +297,24 @@
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.runtime') }}</label>
<select v-model="form.runtimeType" class="form-input">
<option value="native">{{ t('agents.runtime.native') }}</option>
<option value="dsh">{{ t('agents.runtime.dsh') }}</option>
</select>
<p class="form-hint">{{ form.runtimeType === 'dsh' ? t('agents.fields.runtimeDshHint') : t('agents.fields.runtimeNativeHint') }}</p>
</div>
<div v-if="form.runtimeType === 'dsh'" class="form-group full-width">
<label class="form-label">{{ t('agents.fields.runtimeConfig') }}</label>
<textarea v-model="form.runtimeConfig" class="form-textarea runtime-config-editor" rows="4" spellcheck="false" placeholder="{}"></textarea>
<p class="form-hint">{{ t('agents.fields.runtimeConfigHint') }}</p>
<p v-if="dshDiagnostics" class="form-hint runtime-diagnostics" :class="{ 'runtime-diagnostics--ready': dshDiagnostics.executableAvailable && dshDiagnostics.cordisConfigAvailable }">
{{ dshDiagnostics.executableAvailable && dshDiagnostics.cordisConfigAvailable
? t('agents.fields.runtimeReady')
: t('agents.fields.runtimeUnavailable') }}
</p>
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
<input v-model.number="form.maxIterations" type="number" min="1" max="50" class="form-input" />
@ -960,6 +983,7 @@ const selectedProviderPrefs = ref<Array<{ providerId: string; modelId: string |
// global enabled-models list, blank value means "fall back to default".
// id is a string (Snowflake serialised as string).
const availableModels = ref<Array<{ id: string; name: string; provider: string; modelName: string }>>([])
const dshDiagnostics = ref<Record<string, any> | 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<Agent> & { 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); }

View File

@ -63,6 +63,13 @@
<div class="agent-badge-text">
<span class="agent-badge-name">{{ currentAgent.name }}</span>
</div>
<span
class="agent-runtime-badge"
:class="{ 'agent-runtime-badge--dsh': currentAgentRuntimeType === 'dsh' }"
:title="currentAgentRuntimeLabel"
>
{{ currentAgentRuntimeLabel }}
</span>
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
</div>
</div>
@ -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;