mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): integrate DeepSeek Harness runtime
This commit is contained in:
parent
0eb5a1dd40
commit
473ed5786e
1
.gitignore
vendored
1
.gitignore
vendored
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package vip.mate.agent.runtime.contract;
|
||||
|
||||
public record RuntimeCapabilities(
|
||||
boolean supportsCancellation,
|
||||
boolean supportsApprovals,
|
||||
boolean supportsSubagents,
|
||||
boolean supportsContextUsage
|
||||
) {}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {}
|
||||
@ -0,0 +1,11 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public interface DshProcessHandle {
|
||||
boolean isAlive();
|
||||
|
||||
void destroy();
|
||||
|
||||
void destroyForcibly();
|
||||
|
||||
boolean awaitExit(long millis);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package vip.mate.agent.runtime.dsh;
|
||||
|
||||
public enum DshToolDecision {
|
||||
ALLOW,
|
||||
APPROVAL,
|
||||
DENY
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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) = '';
|
||||
@ -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';
|
||||
@ -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) = '';
|
||||
@ -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() {
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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())));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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); }
|
||||
}
|
||||
}
|
||||
@ -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")));
|
||||
}
|
||||
}
|
||||
@ -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) =>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '标签',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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); }
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user