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 local runtime data (H2 DB, logs, etc. - do not commit)
|
||||||
mateclaw-server/data/
|
mateclaw-server/data/
|
||||||
|
.sessions/
|
||||||
/data/
|
/data/
|
||||||
|
|
||||||
# VitePress build output and cache (do not commit)
|
# 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 vip.mate.workspace.conversation.repository.ConversationMapper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
@ -78,6 +80,13 @@ public class AgentService {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private ProgressLedgerService progressLedgerService;
|
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
|
* 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
|
* 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) {
|
if (agent.getAgentType() == null) {
|
||||||
agent.setAgentType("react");
|
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);
|
requireUniqueName(agent, null);
|
||||||
agentMapper.insert(agent);
|
agentMapper.insert(agent);
|
||||||
publishLifecycle(agent, "spawned");
|
publishLifecycle(agent, "spawned");
|
||||||
@ -156,6 +175,9 @@ public class AgentService {
|
|||||||
}
|
}
|
||||||
requireUniqueName(agent, agent.getId());
|
requireUniqueName(agent, agent.getId());
|
||||||
}
|
}
|
||||||
|
if ("dsh".equalsIgnoreCase(agent.getRuntimeType())) {
|
||||||
|
validateDshConfiguration(agent);
|
||||||
|
}
|
||||||
agentMapper.updateById(agent);
|
agentMapper.updateById(agent);
|
||||||
agentInstances.remove(agent.getId());
|
agentInstances.remove(agent.getId());
|
||||||
if (prior != null && prior.getEnabled() != null
|
if (prior != null && prior.getEnabled() != null
|
||||||
@ -299,6 +321,10 @@ public class AgentService {
|
|||||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
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);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -336,6 +362,12 @@ public class AgentService {
|
|||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
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);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
@ -373,6 +405,19 @@ public class AgentService {
|
|||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
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);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
|
|
||||||
// 设置请求级思考深度(通过 ThreadLocal 传递到 StateGraph 执行)
|
// 设置请求级思考深度(通过 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.
|
* Prepend memory-context block to user message if non-empty.
|
||||||
* Does not pollute build-time system prompt snapshot.
|
* Does not pollute build-time system prompt snapshot.
|
||||||
|
|||||||
@ -26,6 +26,14 @@ public class AgentEntity {
|
|||||||
/** Agent 类型:react / plan_execute */
|
/** Agent 类型:react / plan_execute */
|
||||||
private String agentType;
|
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)
|
@TableField(value = "system_prompt", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String systemPrompt;
|
private String systemPrompt;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
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
|
* 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 AuditEventService auditEventService;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
private final I18nService i18nService;
|
private final I18nService i18nService;
|
||||||
|
private final DshRuntimeService dshRuntimeService;
|
||||||
|
|
||||||
@Operation(summary = "Snapshot of every in-flight agent turn")
|
@Operation(summary = "Snapshot of every in-flight agent turn")
|
||||||
@GetMapping("/snapshot")
|
@GetMapping("/snapshot")
|
||||||
@ -49,6 +51,14 @@ public class AgentRuntimeController {
|
|||||||
return R.ok(aggregator.snapshot());
|
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")
|
@Operation(summary = "Friendly stop — request the run to wind down at its next checkpoint")
|
||||||
@PostMapping("/runs/{conversationId}/stop")
|
@PostMapping("/runs/{conversationId}/stop")
|
||||||
@RequireGlobalAdmin
|
@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 自定义配置
|
||||||
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:
|
server:
|
||||||
# Public base URL used to build absolute download links for tool-generated
|
# 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
|
# 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.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -93,6 +94,36 @@ class AgentServiceUniquenessTest {
|
|||||||
assertNotEquals(a.getId(), b.getId());
|
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
|
@Test
|
||||||
@DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)")
|
@DisplayName("createAgent 拒绝空名(fail-fast 在 unique 检查之前)")
|
||||||
void createRejectsBlankName() {
|
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 = {
|
export const liveApi = {
|
||||||
snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'),
|
snapshot: () => http.get<{ data: LiveSnapshot }>('/admin/agent-runtime/snapshot'),
|
||||||
|
dshDiagnostics: () => http.get<{ data: Record<string, any> }>('/admin/agent-runtime/dsh/diagnostics'),
|
||||||
stop: (conversationId: string) =>
|
stop: (conversationId: string) =>
|
||||||
http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`),
|
http.post(`/admin/agent-runtime/runs/${encodeURIComponent(conversationId)}/stop`),
|
||||||
recycle: (conversationId: string) =>
|
recycle: (conversationId: string) =>
|
||||||
|
|||||||
@ -1572,6 +1572,7 @@ export default {
|
|||||||
all: 'All',
|
all: 'All',
|
||||||
react: 'ReAct',
|
react: 'ReAct',
|
||||||
planExecute: 'Plan-Execute',
|
planExecute: 'Plan-Execute',
|
||||||
|
dsh: 'DSH Harness',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
basic: 'Basic',
|
basic: 'Basic',
|
||||||
@ -1605,6 +1606,13 @@ export default {
|
|||||||
name: 'Name',
|
name: 'Name',
|
||||||
icon: 'Icon',
|
icon: 'Icon',
|
||||||
type: 'Type',
|
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',
|
description: 'Description',
|
||||||
systemPrompt: 'System Prompt',
|
systemPrompt: 'System Prompt',
|
||||||
role: 'Role',
|
role: 'Role',
|
||||||
@ -1683,6 +1691,11 @@ export default {
|
|||||||
deleteSuccess: 'Employee let go',
|
deleteSuccess: 'Employee let go',
|
||||||
toggleFailed: 'Failed to toggle status',
|
toggleFailed: 'Failed to toggle status',
|
||||||
toggleSuccess: 'Status updated',
|
toggleSuccess: 'Status updated',
|
||||||
|
runtimeConfigInvalid: 'DSH configuration must be a valid JSON object',
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
native: 'Native Loop',
|
||||||
|
dsh: 'DSH Harness',
|
||||||
},
|
},
|
||||||
tagFilter: {
|
tagFilter: {
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
|
|||||||
@ -1429,6 +1429,7 @@ export default {
|
|||||||
all: '全部',
|
all: '全部',
|
||||||
react: 'ReAct',
|
react: 'ReAct',
|
||||||
planExecute: 'Plan-Execute',
|
planExecute: 'Plan-Execute',
|
||||||
|
dsh: 'DSH Harness',
|
||||||
enabled: '已启用',
|
enabled: '已启用',
|
||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
basic: '基本信息',
|
basic: '基本信息',
|
||||||
@ -1462,6 +1463,13 @@ export default {
|
|||||||
name: '名称',
|
name: '名称',
|
||||||
icon: '图标',
|
icon: '图标',
|
||||||
type: '类型',
|
type: '类型',
|
||||||
|
runtime: '运行时',
|
||||||
|
runtimeConfig: 'DSH 配置(JSON)',
|
||||||
|
runtimeNativeHint: '使用 MateClaw 原生 Agent Loop。',
|
||||||
|
runtimeDshHint: '使用 DeepSeek Harness Loop;启动时会校验工作区和 provider。',
|
||||||
|
runtimeConfigHint: '填写 provider 需要的 JSON 配置;留空表示使用默认配置。',
|
||||||
|
runtimeReady: 'DSH 运行时已就绪(命令与 Cordis 配置可用)。',
|
||||||
|
runtimeUnavailable: 'DSH 运行时不可用,请检查命令路径和 Cordis 配置。',
|
||||||
description: '描述',
|
description: '描述',
|
||||||
systemPrompt: '系统提示词',
|
systemPrompt: '系统提示词',
|
||||||
role: '岗位(Role)',
|
role: '岗位(Role)',
|
||||||
@ -1540,6 +1548,11 @@ export default {
|
|||||||
deleteSuccess: '员工已离职',
|
deleteSuccess: '员工已离职',
|
||||||
toggleFailed: '切换状态失败',
|
toggleFailed: '切换状态失败',
|
||||||
toggleSuccess: '状态已更新',
|
toggleSuccess: '状态已更新',
|
||||||
|
runtimeConfigInvalid: 'DSH 配置必须是合法的 JSON 对象',
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
native: 'Native Loop',
|
||||||
|
dsh: 'DSH Harness',
|
||||||
},
|
},
|
||||||
tagFilter: {
|
tagFilter: {
|
||||||
label: '标签',
|
label: '标签',
|
||||||
|
|||||||
@ -37,6 +37,8 @@ export interface Agent {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
agentType: 'react' | 'plan_execute'
|
agentType: 'react' | 'plan_execute'
|
||||||
|
runtimeType?: 'native' | 'dsh' | string
|
||||||
|
runtimeConfig?: string | null
|
||||||
systemPrompt?: string
|
systemPrompt?: string
|
||||||
modelName?: string
|
modelName?: string
|
||||||
maxIterations: number
|
maxIterations: number
|
||||||
|
|||||||
@ -99,6 +99,18 @@
|
|||||||
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
|
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="wiz-field wiz-full">
|
||||||
<label>{{ t('agents.wizard.fields.description') }}</label>
|
<label>{{ t('agents.wizard.fields.description') }}</label>
|
||||||
<input v-model="draft.description" class="wiz-control" />
|
<input v-model="draft.description" class="wiz-control" />
|
||||||
@ -234,6 +246,8 @@ interface Draft {
|
|||||||
icon: string
|
icon: string
|
||||||
description: string
|
description: string
|
||||||
agentType: string
|
agentType: string
|
||||||
|
runtimeType: 'native' | 'dsh'
|
||||||
|
runtimeConfig: string | null
|
||||||
systemPrompt: string
|
systemPrompt: string
|
||||||
role?: string
|
role?: string
|
||||||
goal?: string
|
goal?: string
|
||||||
@ -308,6 +322,8 @@ async function generate() {
|
|||||||
icon: d.icon || '🤖',
|
icon: d.icon || '🤖',
|
||||||
description: d.description || '',
|
description: d.description || '',
|
||||||
agentType: d.agentType || 'react',
|
agentType: d.agentType || 'react',
|
||||||
|
runtimeType: d.runtimeType === 'dsh' ? 'dsh' : 'native',
|
||||||
|
runtimeConfig: d.runtimeConfig || null,
|
||||||
systemPrompt: d.systemPrompt || '',
|
systemPrompt: d.systemPrompt || '',
|
||||||
role: d.role,
|
role: d.role,
|
||||||
goal: d.goal,
|
goal: d.goal,
|
||||||
@ -343,6 +359,8 @@ async function confirmCreate() {
|
|||||||
icon: draft.value.icon,
|
icon: draft.value.icon,
|
||||||
description: draft.value.description,
|
description: draft.value.description,
|
||||||
agentType: draft.value.agentType,
|
agentType: draft.value.agentType,
|
||||||
|
runtimeType: draft.value.runtimeType,
|
||||||
|
runtimeConfig: draft.value.runtimeType === 'dsh' ? (draft.value.runtimeConfig || '{}') : null,
|
||||||
systemPrompt: draft.value.systemPrompt,
|
systemPrompt: draft.value.systemPrompt,
|
||||||
tags,
|
tags,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -457,6 +475,8 @@ function goRoster() {
|
|||||||
.wiz-field.wiz-full { grid-column: 1 / -1; }
|
.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 > label { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin-bottom: 6px; }
|
||||||
.wiz-field .req { color: var(--mc-primary); }
|
.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);
|
.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);
|
border-radius: 10px; background: var(--mc-input-bg); font-size: 14px; color: var(--mc-text-primary);
|
||||||
font-family: inherit; outline: none; }
|
font-family: inherit; outline: none; }
|
||||||
|
|||||||
@ -114,6 +114,11 @@
|
|||||||
<p class="agent-card__tagline">
|
<p class="agent-card__tagline">
|
||||||
{{ agentTagline(agent) || t('agents.messages.noTagline') }}
|
{{ agentTagline(agent) || t('agents.messages.noTagline') }}
|
||||||
</p>
|
</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">
|
<div v-if="agentTags(agent).length" class="agent-card__tags">
|
||||||
<span v-for="tag in agentTags(agent)" :key="tag" class="agent-card__tag"
|
<span v-for="tag in agentTags(agent)" :key="tag" class="agent-card__tag"
|
||||||
:class="{ active: activeTags.includes(tag) }" @click="toggleTag(tag)">
|
:class="{ active: activeTags.includes(tag) }" @click="toggleTag(tag)">
|
||||||
@ -292,6 +297,24 @@
|
|||||||
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
|
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
|
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
|
||||||
<input v-model.number="form.maxIterations" type="number" min="1" max="50" class="form-input" />
|
<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".
|
// global enabled-models list, blank value means "fall back to default".
|
||||||
// id is a string (Snowflake serialised as string).
|
// id is a string (Snowflake serialised as string).
|
||||||
const availableModels = ref<Array<{ id: string; name: string; provider: string; modelName: string }>>([])
|
const availableModels = ref<Array<{ id: string; name: string; provider: string; modelName: string }>>([])
|
||||||
|
const dshDiagnostics = ref<Record<string, any> | null>(null)
|
||||||
|
|
||||||
// Template selector state
|
// Template selector state
|
||||||
const showTemplateSelector = ref(false)
|
const showTemplateSelector = ref(false)
|
||||||
@ -970,6 +994,7 @@ const filterTabs = [
|
|||||||
{ key: 'agents.tabs.all', value: 'all' },
|
{ key: 'agents.tabs.all', value: 'all' },
|
||||||
{ key: 'agents.tabs.react', value: 'react' },
|
{ key: 'agents.tabs.react', value: 'react' },
|
||||||
{ key: 'agents.tabs.planExecute', value: 'plan_execute' },
|
{ key: 'agents.tabs.planExecute', value: 'plan_execute' },
|
||||||
|
{ key: 'agents.tabs.dsh', value: 'dsh' },
|
||||||
{ key: 'agents.tabs.enabled', value: 'enabled' },
|
{ key: 'agents.tabs.enabled', value: 'enabled' },
|
||||||
{ key: 'agents.tabs.disabled', value: 'disabled' },
|
{ key: 'agents.tabs.disabled', value: 'disabled' },
|
||||||
]
|
]
|
||||||
@ -978,6 +1003,8 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
|
|||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
agentType: 'react',
|
agentType: 'react',
|
||||||
|
runtimeType: 'native',
|
||||||
|
runtimeConfig: null,
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
modelName: '', // RFC-03 G1 — empty means "use global default"
|
modelName: '', // RFC-03 G1 — empty means "use global default"
|
||||||
maxIterations: 10,
|
maxIterations: 10,
|
||||||
@ -1145,6 +1172,7 @@ const filteredAgents = computed(() => {
|
|||||||
}
|
}
|
||||||
if (activeFilter.value === 'react') list = list.filter(a => a.agentType === 'react')
|
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 === '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 === 'enabled') list = list.filter(a => a.enabled)
|
||||||
else if (activeFilter.value === 'disabled') 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.
|
// 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(() => {
|
onMounted(() => {
|
||||||
loadAgents()
|
loadAgents()
|
||||||
|
loadDshDiagnostics()
|
||||||
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
// RFC-03 G1: load models once for the per-Agent override dropdown.
|
||||||
// Failure is non-fatal — the dropdown just shows only "global default".
|
// Failure is non-fatal — the dropdown just shows only "global default".
|
||||||
loadAvailableModels()
|
loadAvailableModels()
|
||||||
@ -1342,6 +1380,8 @@ async function openEditModal(agent: Agent) {
|
|||||||
name: agent.name,
|
name: agent.name,
|
||||||
description: agent.description || '',
|
description: agent.description || '',
|
||||||
agentType: agent.agentType,
|
agentType: agent.agentType,
|
||||||
|
runtimeType: agent.runtimeType || 'native',
|
||||||
|
runtimeConfig: agent.runtimeConfig || null,
|
||||||
systemPrompt: agent.systemPrompt || '',
|
systemPrompt: agent.systemPrompt || '',
|
||||||
modelName: agent.modelName || '',
|
modelName: agent.modelName || '',
|
||||||
maxIterations: agent.maxIterations,
|
maxIterations: agent.maxIterations,
|
||||||
@ -1440,6 +1480,18 @@ function closeModal() {
|
|||||||
|
|
||||||
async function saveAgent() {
|
async function saveAgent() {
|
||||||
try {
|
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
|
// Flatten the structured profile back to a single systemPrompt before
|
||||||
// sending to the backend — the schema is unchanged, only the editor
|
// sending to the backend — the schema is unchanged, only the editor
|
||||||
// exposes the H2 sections to the user.
|
// exposes the H2 sections to the user.
|
||||||
@ -1740,6 +1792,30 @@ html.dark .seg-count.warn {
|
|||||||
letter-spacing: -0.005em;
|
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__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 { 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); }
|
.agent-card__tag:hover { color: var(--mc-text-secondary); }
|
||||||
|
|||||||
@ -63,6 +63,13 @@
|
|||||||
<div class="agent-badge-text">
|
<div class="agent-badge-text">
|
||||||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||||||
</div>
|
</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>
|
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -873,6 +880,9 @@ const currentAgentRuntimeMode = computed(() => {
|
|||||||
return a.agentType === 'react' ? t('agents.types.react') : t('agents.types.planExecute')
|
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).
|
// Per-conversation last-viewed timestamp store (localStorage-backed, MVP).
|
||||||
// Keyed by conversationId. Updated when the user opens a conversation; the
|
// Keyed by conversationId. Updated when the user opens a conversation; the
|
||||||
// sidebar reads it (ConversationSidebar.hasUnread) to render the accent dot.
|
// sidebar reads it (ConversationSidebar.hasUnread) to render the accent dot.
|
||||||
@ -2651,6 +2661,26 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
line-height: 1.2;
|
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 {
|
.status-dot {
|
||||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user