mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-11-03 06:25:32 +08:00
fix(dsh): pass bounded model output tokens to SDK (#632)
This commit is contained in:
parent
3643052e9f
commit
5c67af85fc
@ -19,6 +19,7 @@ import vip.mate.agent.runtime.contract.RuntimeValidation;
|
|||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
||||||
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||||
import vip.mate.agent.AgentService;
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
import vip.mate.llm.model.ModelConfigEntity;
|
import vip.mate.llm.model.ModelConfigEntity;
|
||||||
import vip.mate.llm.model.ModelProviderEntity;
|
import vip.mate.llm.model.ModelProviderEntity;
|
||||||
import vip.mate.llm.service.ModelConfigService;
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
@ -53,16 +54,21 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
private final ModelConfigService modelConfigService;
|
private final ModelConfigService modelConfigService;
|
||||||
private final ModelProviderService modelProviderService;
|
private final ModelProviderService modelProviderService;
|
||||||
private final DshRuntimeConfigService runtimeConfigService;
|
private final DshRuntimeConfigService runtimeConfigService;
|
||||||
|
private final ConversationWindowProperties windowProperties;
|
||||||
|
private static final int DEFAULT_MAX_OUTPUT_TOKENS = 4096;
|
||||||
|
private static final int DEFAULT_CONTEXT_WINDOW = 128000;
|
||||||
|
|
||||||
public DshRuntimeService(
|
public DshRuntimeService(
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ModelConfigService modelConfigService,
|
ModelConfigService modelConfigService,
|
||||||
ModelProviderService modelProviderService,
|
ModelProviderService modelProviderService,
|
||||||
DshRuntimeConfigService runtimeConfigService) {
|
DshRuntimeConfigService runtimeConfigService,
|
||||||
|
ConversationWindowProperties windowProperties) {
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.modelConfigService = modelConfigService;
|
this.modelConfigService = modelConfigService;
|
||||||
this.modelProviderService = modelProviderService;
|
this.modelProviderService = modelProviderService;
|
||||||
this.runtimeConfigService = runtimeConfigService;
|
this.runtimeConfigService = runtimeConfigService;
|
||||||
|
this.windowProperties = windowProperties;
|
||||||
DshRuntimeConfiguration configuration = runtimeConfig();
|
DshRuntimeConfiguration configuration = runtimeConfig();
|
||||||
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
|
log.info("[DSH] runtime configured: command={}, cordisConfig={}", configuration.executablePath(),
|
||||||
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
|
configuration.cordisConfigPath().isBlank() ? "<empty>" : configuration.cordisConfigPath());
|
||||||
@ -245,8 +251,10 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
String dshSessionId = conversationId + "-" + UUID.randomUUID();
|
String dshSessionId = conversationId + "-" + UUID.randomUUID();
|
||||||
Files.createDirectories(session.workingDirectory());
|
Files.createDirectories(session.workingDirectory());
|
||||||
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
String requestedModel = modelName == null || modelName.isBlank() ? configuration.modelName() : modelName;
|
||||||
ModelProviderEntity provider = resolveProvider(requestedModel);
|
ModelConfigEntity model = resolveModel(requestedModel);
|
||||||
String effectiveModelName = resolveModelName(requestedModel);
|
ModelProviderEntity provider = resolveProvider(model);
|
||||||
|
String effectiveModelName = resolveModelName(requestedModel, model);
|
||||||
|
int maxOutputTokens = resolveMaxOutputTokens(model, windowProperties.getDefaultMaxInputTokens());
|
||||||
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
log.debug("[DSH] model route: requestedModel={}, effectiveModel={}, provider={}, apiKeyConfigured={}, baseUrlConfigured={}",
|
||||||
modelName == null || modelName.isBlank() ? "<default>" : modelName,
|
modelName == null || modelName.isBlank() ? "<default>" : modelName,
|
||||||
effectiveModelName,
|
effectiveModelName,
|
||||||
@ -284,7 +292,8 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
send(writer, request("initialize", "init-" + conversationId, Map.of(
|
send(writer, request("initialize", "init-" + conversationId, Map.of(
|
||||||
"cwd", session.workingDirectory().toString(),
|
"cwd", session.workingDirectory().toString(),
|
||||||
"provider", "deepseek-official",
|
"provider", "deepseek-official",
|
||||||
"model", effectiveModelName)));
|
"model", effectiveModelName,
|
||||||
|
"maxTokens", maxOutputTokens)));
|
||||||
awaitResponse(reader, "init-" + conversationId);
|
awaitResponse(reader, "init-" + conversationId);
|
||||||
long sequence = 0;
|
long sequence = 0;
|
||||||
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
sink.next(RuntimeEventProjector.project(RuntimeEvent.of(
|
||||||
@ -487,13 +496,15 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
return primary != null && !primary.isBlank() ? primary : fallback;
|
return primary != null && !primary.isBlank() ? primary : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private ModelProviderEntity resolveProvider(String modelName) {
|
private ModelConfigEntity resolveModel(String modelName) {
|
||||||
ModelConfigEntity model = null;
|
|
||||||
try {
|
try {
|
||||||
model = modelConfigService.resolveModel(modelName);
|
return modelConfigService.resolveModel(modelName);
|
||||||
} catch (RuntimeException ignored) {
|
} catch (RuntimeException ignored) {
|
||||||
// Fall back to the dedicated DeepSeek provider below.
|
return null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ModelProviderEntity resolveProvider(ModelConfigEntity model) {
|
||||||
if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) {
|
if (model != null && model.getProvider() != null && !model.getProvider().isBlank()) {
|
||||||
try {
|
try {
|
||||||
return modelProviderService.getProviderConfig(model.getProvider());
|
return modelProviderService.getProviderConfig(model.getProvider());
|
||||||
@ -508,18 +519,30 @@ public class DshRuntimeService implements AgentRuntimeProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String resolveModelName(String modelName) {
|
private static String resolveModelName(String modelName, ModelConfigEntity model) {
|
||||||
try {
|
|
||||||
ModelConfigEntity model = modelConfigService.resolveModel(modelName);
|
|
||||||
if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) {
|
if (model != null && model.getModelName() != null && !model.getModelName().isBlank()) {
|
||||||
return model.getModelName();
|
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;
|
return modelName == null || modelName.isBlank() ? "deepseek-v4-flash" : modelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SDK initialize.maxTokens is inherited by main and in-process child agents.
|
||||||
|
* Never let an unset model cap fall through to DSH's 256000-token default.
|
||||||
|
* As in the graph runtime, reserve at least half the known window for input.
|
||||||
|
* This is a static bound, not a token count of DSH's growing tool history.
|
||||||
|
*/
|
||||||
|
static int resolveMaxOutputTokens(ModelConfigEntity model, int defaultWindow) {
|
||||||
|
int output = model != null && model.getMaxTokens() != null && model.getMaxTokens() > 0
|
||||||
|
? model.getMaxTokens() : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||||
|
int window = model != null && model.getMaxInputTokens() != null && model.getMaxInputTokens() > 0
|
||||||
|
? model.getMaxInputTokens() : defaultWindow > 0 ? defaultWindow : DEFAULT_CONTEXT_WINDOW;
|
||||||
|
if (window < 2) {
|
||||||
|
throw new IllegalArgumentException("DSH 模型上下文窗口过小,无法同时容纳输入和输出,请检查模型配置");
|
||||||
|
}
|
||||||
|
return Math.min(output, window / 2);
|
||||||
|
}
|
||||||
|
|
||||||
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
RuntimeEvent mapEvent(String sessionId, long sequence, JsonNode event) {
|
||||||
String type = event.path("type").asText("");
|
String type = event.path("type").asText("");
|
||||||
JsonNode data = event.path("data");
|
JsonNode data = event.path("data");
|
||||||
|
|||||||
@ -86,7 +86,11 @@ DSH_CWD=/var/lib/mateclaw/workspace
|
|||||||
4. Enter the DeepSeek API key and base URL.
|
4. Enter the DeepSeek API key and base URL.
|
||||||
5. Confirm that at least one enabled DeepSeek chat model exists.
|
5. Confirm that at least one enabled DeepSeek chat model exists.
|
||||||
|
|
||||||
The default DSH model is `deepseek-v4-flash`. If the employee has no explicit model, MateClaw uses the global model name and injects credentials from the `deepseek` provider. A custom model must be usable by the DeepSeek provider route in DSH.
|
MateClaw resolves the model through its model configuration, using the global default when none is specified. If configuration resolution is unavailable, an explicitly requested model name is retained; `deepseek-v4-flash` is the fallback only when that name is also empty. Dedicated DSH credentials and endpoint settings take precedence; otherwise, MateClaw reads the selected model's provider configuration, falling back to the `deepseek` provider as needed. A custom model must be usable by the DeepSeek provider route in DSH.
|
||||||
|
|
||||||
|
MateClaw explicitly sends the model's maximum output tokens through SDK `initialize.maxTokens`, avoiding DSH's 256000 default. An unset or invalid output cap defaults to 4096. The cap is also limited to half the known context window to reserve space for input. The model's configured window takes precedence over the global conversation window. For a 128000-token window, an 8192 output cap stays 8192; an oversized 256000 cap becomes 64000.
|
||||||
|
|
||||||
|
This is a static output bound, not a live token count of the complete DSH request. SDK initialization has no direct context-window field; DSH's internal context capacity and compaction remain controlled by its runtime/Cordis configuration. Long tool histories can still exceed the window. Upgrade older DSH versions that do not support `initialize.maxTokens`.
|
||||||
|
|
||||||
## Create a DSH digital employee
|
## Create a DSH digital employee
|
||||||
|
|
||||||
|
|||||||
@ -86,7 +86,11 @@ DSH_CWD=/var/lib/mateclaw/workspace
|
|||||||
4. 填入 DeepSeek API Key 和 Base URL。
|
4. 填入 DeepSeek API Key 和 Base URL。
|
||||||
5. 确认至少有一个启用的 DeepSeek chat 模型。
|
5. 确认至少有一个启用的 DeepSeek chat 模型。
|
||||||
|
|
||||||
DSH 默认模型是 `deepseek-v4-flash`。如果员工没有绑定具体模型,MateClaw 会使用全局默认模型名,并从 `deepseek` 提供商注入凭证。自定义模型时,模型必须能由 DeepSeek Harness 的 DeepSeek provider route 使用。
|
模型名称通过 MateClaw 模型配置解析,未指定时使用全局默认模型;未解析到模型配置时保留明确指定的模型名,连模型名也为空时才兜底为 `deepseek-v4-flash`。凭证和地址优先使用 DSH 专有设置,否则读取所选模型的提供商配置,必要时回退到 `deepseek` 提供商。自定义模型必须能由 DeepSeek Harness 的 DeepSeek provider route 使用。
|
||||||
|
|
||||||
|
MateClaw 会通过 DSH SDK 的 `initialize.maxTokens` 显式传递模型最大输出 token 数,避免继承 DSH 的 256000 默认值。未配置或配置无效时使用 4096,并限制为已知上下文窗口的一半,给输入预留空间。上下文窗口优先取模型的配置,未设置时取全局会话窗口配置。例如窗口为 128000、最大输出为 8192 时发送 8192;最大输出误设为 256000 时发送 64000。
|
||||||
|
|
||||||
|
这是输出上限的静态保护,不是对完整 DSH 请求的实时 token 计数。SDK 初始化协议没有直接设置上下文窗口的字段;DSH 内部上下文容量和压缩仍由其运行时/Cordis 配置管理。长工具调用历史仍可能超出窗口。若使用不支持 `initialize.maxTokens` 的旧版 DSH,请升级到支持该字段的版本。
|
||||||
|
|
||||||
## 创建 DSH 数字员工
|
## 创建 DSH 数字员工
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,101 @@
|
|||||||
|
package vip.mate.agent.runtime.dsh;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfigService;
|
||||||
|
import vip.mate.agent.runtime.dsh.management.DshRuntimeConfiguration;
|
||||||
|
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.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/** Captures the real subprocess boundary instead of testing an unused request builder. */
|
||||||
|
class DshRuntimeModelLimitsTest {
|
||||||
|
@TempDir Path temp;
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({
|
||||||
|
"8192,128000,8192,custom-model,128000",
|
||||||
|
"256000,128000,64000,custom-model,128000",
|
||||||
|
"16384,8192,4096,custom-model,128000",
|
||||||
|
",128000,4096,custom-model,128000",
|
||||||
|
"0,128000,4096,custom-model,128000",
|
||||||
|
"-1,0,4096,custom-model,128000",
|
||||||
|
"8192,128000,8192,,128000",
|
||||||
|
"256000,,64000,custom-model,128000",
|
||||||
|
"8192,0,512,custom-model,1024",
|
||||||
|
"8192,-1,512,custom-model,1024",
|
||||||
|
",,4096,uncatalogued,128000",
|
||||||
|
",,512,uncatalogued,1024",
|
||||||
|
"-1,-1,4096,custom-model,-1"
|
||||||
|
})
|
||||||
|
void sendsBoundedConfiguredOutputBudget(Integer output, Integer window, int expected, String requested, int globalWindow) throws Exception {
|
||||||
|
Path capture = temp.resolve("initialize.json");
|
||||||
|
Path script = temp.resolve("fake-dsh.sh");
|
||||||
|
Files.writeString(script, """
|
||||||
|
#!/bin/sh
|
||||||
|
IFS= read -r initialize
|
||||||
|
printf '%s\\n' "$initialize" > "$1"
|
||||||
|
printf '%s\\n' '{"jsonrpc":"2.0","id":"init-limit-test","result":{}}'
|
||||||
|
IFS= read -r prompt
|
||||||
|
printf '%s\\n' '{"jsonrpc":"2.0","id":"prompt-limit-test","result":{}}'
|
||||||
|
printf '%s\\n' '{"jsonrpc":"2.0","method":"session.status","params":{"status":"idle"}}'
|
||||||
|
""");
|
||||||
|
var config = mock(DshRuntimeConfigService.class);
|
||||||
|
when(config.resolve()).thenReturn(new DshRuntimeConfiguration(
|
||||||
|
"/bin/sh \"" + script + "\" \"" + capture + "\"", "", temp.toString(), "", "", ""));
|
||||||
|
var models = mock(ModelConfigService.class);
|
||||||
|
var model = new ModelConfigEntity();
|
||||||
|
model.setModelName("custom-model");
|
||||||
|
model.setProvider("custom-provider");
|
||||||
|
model.setMaxTokens(output);
|
||||||
|
model.setMaxInputTokens(window);
|
||||||
|
when(models.resolveModel(any())).thenReturn("uncatalogued".equals(requested) ? null : model);
|
||||||
|
var providers = mock(ModelProviderService.class);
|
||||||
|
var provider = new ModelProviderEntity();
|
||||||
|
provider.setBaseUrl("http://127.0.0.1:1/v1");
|
||||||
|
when(providers.getProviderConfig("custom-provider")).thenReturn(provider);
|
||||||
|
var properties = new ConversationWindowProperties();
|
||||||
|
properties.setDefaultMaxInputTokens(globalWindow);
|
||||||
|
var service = new DshRuntimeService(new ObjectMapper(), models, providers, config, properties);
|
||||||
|
var agent = new AgentEntity();
|
||||||
|
agent.setId(1L);
|
||||||
|
agent.setWorkspaceId(2L);
|
||||||
|
var result = service.stream(agent, "hello", "limit-test", requested)
|
||||||
|
.collectList().block(Duration.ofSeconds(5));
|
||||||
|
assertNotNull(result);
|
||||||
|
var params = new ObjectMapper().readTree(Files.readString(capture)).path("params");
|
||||||
|
assertEquals("uncatalogued".equals(requested) ? requested : "custom-model", params.path("model").asText());
|
||||||
|
assertTrue(params.path("maxTokens").isIntegralNumber(), "SDK must receive an explicit numeric output cap");
|
||||||
|
assertEquals(expected, params.path("maxTokens").intValue());
|
||||||
|
assertFalse(params.has("contextWindow"), "SDK initialize does not support this field");
|
||||||
|
verify(models, times(1)).resolveModel(any());
|
||||||
|
}
|
||||||
|
@Test
|
||||||
|
void invalidTinyWindowFailsInsteadOfSendingAnotherImpossibleRequest() {
|
||||||
|
var model = new ModelConfigEntity();
|
||||||
|
model.setMaxInputTokens(1);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> DshRuntimeService.resolveMaxOutputTokens(model, 128000));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tinyWindowCapNeverUsesAFloorLargerThanTheWindow() {
|
||||||
|
var model = new ModelConfigEntity();
|
||||||
|
model.setMaxInputTokens(600);
|
||||||
|
model.setMaxTokens(Integer.MAX_VALUE);
|
||||||
|
assertEquals(300, DshRuntimeService.resolveMaxOutputTokens(model, 128000));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -141,7 +141,7 @@ class DshRuntimeServiceTest {
|
|||||||
executable, "", "/tmp", "", "model", ""));
|
executable, "", "/tmp", "", "model", ""));
|
||||||
return new DshRuntimeService(new ObjectMapper(),
|
return new DshRuntimeService(new ObjectMapper(),
|
||||||
Mockito.mock(ModelConfigService.class),
|
Mockito.mock(ModelConfigService.class),
|
||||||
Mockito.mock(ModelProviderService.class), config);
|
Mockito.mock(ModelProviderService.class), config, new vip.mate.config.ConversationWindowProperties());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RuntimeSession session(Path workingDirectory) {
|
private static RuntimeSession session(Path workingDirectory) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user