Skip to content

Commit 1cb618d

Browse files
committed
gnhf jobrunr#38: Реализован 15.6.1 Agent assignment per role — V36 миграция role_agent_config, RoleAgentConfig entity/repository/service с hierarchy fallback и кэшем, интеграция model override через весь pipeline (ChatRestController→SseStreamingService→ChatService), REST API endpoints для управления, 26 новых тестов, все 1147 тестов зелёные
1 parent f585fa4 commit 1cb618d

15 files changed

Lines changed: 726 additions & 25 deletions

File tree

javaclaw-api/javaclaw-api-admin/src/main/java/ai/javaclaw/api/admin/users/RoleController.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package ai.javaclaw.api.admin.users;
22

3+
import ai.javaclaw.agent.config.RoleAgentConfig;
4+
import ai.javaclaw.agent.config.RoleAgentConfigService;
35
import ai.javaclaw.users.CustomRole;
46
import ai.javaclaw.users.CustomRoleService;
57
import ai.javaclaw.users.Permission;
@@ -22,9 +24,11 @@
2224
public class RoleController {
2325

2426
private final CustomRoleService roleService;
27+
private final RoleAgentConfigService agentConfigService;
2528

26-
public RoleController(CustomRoleService roleService) {
29+
public RoleController(CustomRoleService roleService, RoleAgentConfigService agentConfigService) {
2730
this.roleService = roleService;
31+
this.agentConfigService = agentConfigService;
2832
}
2933

3034
@GetMapping
@@ -90,6 +94,68 @@ public record UpdateDescriptionRequest(String description) {}
9094

9195
public record SetPermissionsRequest(List<String> permissions) {}
9296

97+
// --- Agent config endpoints (15.6.1) ---
98+
99+
@GetMapping("/{name}/agent-config")
100+
public ResponseEntity<AgentConfigDto> getAgentConfig(@PathVariable String name) {
101+
return agentConfigService
102+
.getForRole(name)
103+
.map(this::toAgentConfigDto)
104+
.map(ResponseEntity::ok)
105+
.orElse(ResponseEntity.notFound().build());
106+
}
107+
108+
@GetMapping("/agent-configs")
109+
public List<AgentConfigDto> listAgentConfigs() {
110+
return agentConfigService.listAll().stream().map(this::toAgentConfigDto).toList();
111+
}
112+
113+
@PutMapping("/{name}/agent-config")
114+
public ResponseEntity<AgentConfigDto> saveAgentConfig(
115+
@PathVariable String name, @RequestBody SaveAgentConfigRequest request) {
116+
RoleAgentConfig saved = agentConfigService.save(
117+
name,
118+
request.modelId(),
119+
request.thinkingEnabled() != null ? request.thinkingEnabled() : false,
120+
request.thinkingBudget() != null ? request.thinkingBudget() : 10000,
121+
request.fallbackModels(),
122+
request.maxContextTokens() != null ? request.maxContextTokens() : 200000);
123+
return ResponseEntity.ok(toAgentConfigDto(saved));
124+
}
125+
126+
@DeleteMapping("/{name}/agent-config")
127+
public ResponseEntity<Void> deleteAgentConfig(@PathVariable String name) {
128+
agentConfigService.delete(name);
129+
return ResponseEntity.noContent().build();
130+
}
131+
132+
private AgentConfigDto toAgentConfigDto(RoleAgentConfig config) {
133+
return new AgentConfigDto(
134+
config.role(),
135+
config.modelId(),
136+
config.thinkingEnabled(),
137+
config.thinkingBudget(),
138+
config.fallbackModels(),
139+
config.fallbackModelList(),
140+
config.maxContextTokens());
141+
}
142+
143+
public record AgentConfigDto(
144+
String role,
145+
String modelId,
146+
boolean thinkingEnabled,
147+
int thinkingBudget,
148+
String fallbackModels,
149+
List<String> fallbackModelList,
150+
int maxContextTokens) {}
151+
152+
public record SaveAgentConfigRequest(
153+
String modelId,
154+
Boolean thinkingEnabled,
155+
Integer thinkingBudget,
156+
String fallbackModels,
157+
Integer maxContextTokens) {}
158+
93159
@org.springframework.web.bind.annotation.ExceptionHandler(IllegalArgumentException.class)
94160
public ResponseEntity<java.util.Map<String, String>> handleBadRequest(IllegalArgumentException ex) {
95161
return ResponseEntity.badRequest().body(java.util.Map.of("error", ex.getMessage()));

javaclaw-api/javaclaw-api-chat/src/main/java/ai/javaclaw/api/chat/controller/ChatRestController.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package ai.javaclaw.api.chat.controller;
22

3+
import ai.javaclaw.agent.config.RoleAgentConfigService;
34
import ai.javaclaw.agent.quota.AgentQuotaService;
45
import ai.javaclaw.api.chat.controller.dto.ChatSendRequest;
56
import ai.javaclaw.api.chat.service.SseStreamingService;
@@ -40,6 +41,7 @@ public class ChatRestController {
4041
private final ConversationEnsurer conversationEnsurer;
4142
private final UserResolver userResolver;
4243
private final AgentQuotaService agentQuotaService;
44+
private final RoleAgentConfigService roleAgentConfigService;
4345

4446
@PostMapping(value = "/send", produces = "text/plain;charset=UTF-8")
4547
public ResponseEntity<ResponseBodyEmitter> send(
@@ -55,9 +57,11 @@ public ResponseEntity<ResponseBodyEmitter> send(
5557
} catch (RateLimitExceededException e) {
5658
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build();
5759
}
60+
final String userRole = userResolver.resolveUserRole(principal.getName());
61+
final String modelOverride = roleAgentConfigService.resolveModelForRole(userRole);
5862
conversationEnsurer.ensureExistsForUser(conversationId, userId);
5963
conversationEnsurer.touch(conversationId, request.content());
60-
streamingService.stream(emitter, conversationId, userId, request.content());
64+
streamingService.stream(emitter, conversationId, userId, request.content(), modelOverride);
6165
return ResponseEntity.ok()
6266
.header(VERCEL_STREAM_HEADER, VERCEL_STREAM_VERSION)
6367
.header("x-conversation-id", conversationId)

javaclaw-api/javaclaw-api-chat/src/main/java/ai/javaclaw/api/chat/service/SseStreamingService.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,24 @@ public void stream(
146146
final String conversationId,
147147
final String userId,
148148
final String userContent) {
149+
stream(emitter, conversationId, userId, userContent, null);
150+
}
151+
152+
/**
153+
* Streams the agent's response with per-user prompt support and role-based model override.
154+
*
155+
* @param emitter цель для записи SSE-событий
156+
* @param conversationId идентификатор разговора
157+
* @param userId идентификатор пользователя для per-user промптов, может быть null
158+
* @param userContent сообщение пользователя
159+
* @param modelOverride модель для использования вместо дефолтной, может быть null
160+
*/
161+
public void stream(
162+
final ResponseBodyEmitter emitter,
163+
final String conversationId,
164+
final String userId,
165+
final String userContent,
166+
final String modelOverride) {
149167
final Object lock = new Object();
150168
final ScheduledFuture<?> heartbeat = heartbeatScheduler.scheduleAtFixedRate(
151169
() -> sendHeartbeat(emitter, lock),
@@ -156,7 +174,7 @@ public void stream(
156174
emitter.onTimeout(() -> heartbeat.cancel(false));
157175
emitter.onError(ex -> heartbeat.cancel(false));
158176

159-
streamExecutor.execute(() -> runStream(emitter, lock, conversationId, userId, userContent));
177+
streamExecutor.execute(() -> runStream(emitter, lock, conversationId, userId, userContent, modelOverride));
160178
}
161179

162180
/**
@@ -177,7 +195,8 @@ private void runStream(
177195
final Object lock,
178196
final String conversationId,
179197
final String userId,
180-
final String userContent) {
198+
final String userContent,
199+
final String modelOverride) {
181200
final String messageId = "msg_" + UUID.randomUUID();
182201
final String textBlockId = "text_" + UUID.randomUUID();
183202
final String reasoningBlockId = "reasoning_" + UUID.randomUUID();
@@ -195,7 +214,7 @@ private void runStream(
195214
lock,
196215
VercelSseEvent.MessageStart.builder().messageId(messageId).build());
197216

198-
chatService.stream(conversationId, userId, userContent)
217+
chatService.stream(conversationId, userId, userContent, modelOverride)
199218
.takeUntilOther(cancelSink.asMono())
200219
.doOnNext(response -> {
201220
if (response == null

javaclaw-api/javaclaw-api-chat/src/test/java/ai/javaclaw/api/chat/controller/ChatRestControllerTest.java

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
1212
import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup;
1313

14+
import ai.javaclaw.agent.config.RoleAgentConfigService;
1415
import ai.javaclaw.agent.quota.AgentQuotaService;
1516
import ai.javaclaw.api.chat.error.SseExceptionHandler;
1617
import ai.javaclaw.api.chat.service.SseStreamingService;
@@ -30,6 +31,7 @@ class ChatRestControllerTest {
3031
private ConversationEnsurer conversationEnsurer;
3132
private UserResolver userResolver;
3233
private AgentQuotaService agentQuotaService;
34+
private RoleAgentConfigService roleAgentConfigService;
3335
private MockMvc mockMvc;
3436

3537
@BeforeEach
@@ -38,9 +40,11 @@ void setUp() {
3840
conversationEnsurer = mock(ConversationEnsurer.class);
3941
userResolver = mock(UserResolver.class);
4042
agentQuotaService = mock(AgentQuotaService.class);
43+
roleAgentConfigService = mock(RoleAgentConfigService.class);
4144
when(userResolver.resolveUserId("admin")).thenReturn("admin-uuid");
42-
mockMvc = standaloneSetup(
43-
new ChatRestController(streamingService, conversationEnsurer, userResolver, agentQuotaService))
45+
when(userResolver.resolveUserRole("admin")).thenReturn("ADMIN");
46+
mockMvc = standaloneSetup(new ChatRestController(
47+
streamingService, conversationEnsurer, userResolver, agentQuotaService, roleAgentConfigService))
4448
.defaultRequest(post("/").principal(adminPrincipal()))
4549
.setControllerAdvice(new SseExceptionHandler())
4650
.build();
@@ -53,7 +57,7 @@ private static Principal adminPrincipal() {
5357
@Test
5458
void postSendReturnsOkWithVercelHeader() throws Exception {
5559
when(streamingService.createEmitter()).thenReturn(new ResponseBodyEmitter(5000L));
56-
doNothing().when(streamingService).stream(any(), anyString(), anyString(), anyString());
60+
doNothing().when(streamingService).stream(any(), anyString(), anyString(), anyString(), any());
5761

5862
mockMvc.perform(post("/api/chat/send")
5963
.contentType(MediaType.APPLICATION_JSON)
@@ -62,7 +66,7 @@ void postSendReturnsOkWithVercelHeader() throws Exception {
6266
.andExpect(header().string("x-vercel-ai-data-stream", "v1"))
6367
.andExpect(header().string("Content-Type", "text/plain;charset=UTF-8"));
6468

65-
verify(streamingService).stream(any(), anyString(), anyString(), anyString());
69+
verify(streamingService).stream(any(), anyString(), anyString(), anyString(), any());
6670
verify(conversationEnsurer).ensureExistsForUser("web", "admin-uuid");
6771
verify(conversationEnsurer).touch("web", "hello");
6872
}
@@ -86,7 +90,7 @@ void postSendGeneratesDefaultConversationIdWhenBlank() throws Exception {
8690
.content("{\"content\":\"hello\"}"))
8791
.andExpect(status().isOk());
8892

89-
verify(streamingService).stream(any(), anyString(), anyString(), anyString());
93+
verify(streamingService).stream(any(), anyString(), anyString(), anyString(), any());
9094
}
9195

9296
@Test

javaclaw-api/javaclaw-api-chat/src/test/java/ai/javaclaw/api/chat/service/SseStreamingServiceTest.java

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ void streamEmitsReasoningDeltasForThinkingMetadata() throws Exception {
145145
new ChatResponse(List.of(new Generation(thinkingMsg2))),
146146
new ChatResponse(List.of(new Generation(signatureMsg))),
147147
new ChatResponse(List.of(new Generation(textMsg))));
148-
when(chatService.stream(eq("think-test"), isNull(), eq("why?"))).thenReturn(flux);
148+
when(chatService.stream(eq("think-test"), isNull(), eq("why?"), isNull()))
149+
.thenReturn(flux);
149150

150151
final SseStreamingService service = new SseStreamingService(
151152
chatService,
@@ -198,7 +199,7 @@ void streamHandlesTextOnlyWithoutReasoning() throws Exception {
198199
final Flux<ChatResponse> flux = Flux.just(
199200
new ChatResponse(List.of(new Generation(textMsg1))),
200201
new ChatResponse(List.of(new Generation(textMsg2))));
201-
when(chatService.stream(eq("text-test"), isNull(), eq("hi"))).thenReturn(flux);
202+
when(chatService.stream(eq("text-test"), isNull(), eq("hi"), isNull())).thenReturn(flux);
202203

203204
final SseStreamingService service = new SseStreamingService(
204205
chatService,
@@ -234,7 +235,8 @@ void streamClosesUnfinishedReasoningBlock() throws Exception {
234235
final Flux<ChatResponse> flux = Flux.just(
235236
new ChatResponse(List.of(new Generation(thinkingMsg))),
236237
new ChatResponse(List.of(new Generation(textMsg))));
237-
when(chatService.stream(eq("unfinished-think"), isNull(), eq("q"))).thenReturn(flux);
238+
when(chatService.stream(eq("unfinished-think"), isNull(), eq("q"), isNull()))
239+
.thenReturn(flux);
238240

239241
final SseStreamingService service = new SseStreamingService(
240242
chatService,
@@ -302,7 +304,8 @@ void cancelTerminatesActiveStream() throws Exception {
302304
// Slow flux that emits tokens with delays — simulates long LLM response
303305
final Flux<ChatResponse> slowFlux = Flux.interval(Duration.ofMillis(50))
304306
.map(i -> new ChatResponse(java.util.List.of(new Generation(new AssistantMessage("token" + i)))));
305-
when(chatService.stream(eq("cancel-test"), isNull(), eq("hello"))).thenReturn(slowFlux);
307+
when(chatService.stream(eq("cancel-test"), isNull(), eq("hello"), isNull()))
308+
.thenReturn(slowFlux);
306309

307310
final SseStreamingService service = new SseStreamingService(
308311
chatService,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package ai.javaclaw.agent.config;
2+
3+
import org.springframework.data.annotation.Id;
4+
import org.springframework.data.relational.core.mapping.Column;
5+
import org.springframework.data.relational.core.mapping.Table;
6+
import org.springframework.lang.Nullable;
7+
8+
/**
9+
* Per-role agent configuration: which model to use, thinking settings, fallback chain.
10+
* Stored in {@code role_agent_config} table (V36 migration).
11+
*
12+
* <p>If {@code modelId} is null, the system default model is used for that role.
13+
*/
14+
@Table("role_agent_config")
15+
public record RoleAgentConfig(
16+
@Id String id,
17+
@Column("role") String role,
18+
@Nullable @Column("model_id") String modelId,
19+
@Column("thinking_enabled") boolean thinkingEnabled,
20+
@Column("thinking_budget") int thinkingBudget,
21+
@Nullable @Column("fallback_models") String fallbackModels,
22+
@Column("max_context_tokens") int maxContextTokens) {
23+
24+
/** Create a new config (id generated by DB). */
25+
public static RoleAgentConfig create(
26+
String role,
27+
@Nullable String modelId,
28+
boolean thinkingEnabled,
29+
int thinkingBudget,
30+
@Nullable String fallbackModels,
31+
int maxContextTokens) {
32+
return new RoleAgentConfig(
33+
null, role, modelId, thinkingEnabled, thinkingBudget, fallbackModels, maxContextTokens);
34+
}
35+
36+
/** Returns fallback models as a list (comma-separated in DB). */
37+
public java.util.List<String> fallbackModelList() {
38+
if (fallbackModels == null || fallbackModels.isBlank()) {
39+
return java.util.List.of();
40+
}
41+
return java.util.Arrays.stream(fallbackModels.split(","))
42+
.map(String::trim)
43+
.filter(s -> !s.isEmpty())
44+
.toList();
45+
}
46+
47+
/** Returns a copy with updated fields. */
48+
public RoleAgentConfig withModelId(@Nullable String modelId) {
49+
return new RoleAgentConfig(
50+
id, role, modelId, thinkingEnabled, thinkingBudget, fallbackModels, maxContextTokens);
51+
}
52+
53+
public RoleAgentConfig withThinking(boolean enabled, int budget) {
54+
return new RoleAgentConfig(id, role, modelId, enabled, budget, fallbackModels, maxContextTokens);
55+
}
56+
57+
public RoleAgentConfig withFallbackModels(@Nullable String fallbackModels) {
58+
return new RoleAgentConfig(
59+
id, role, modelId, thinkingEnabled, thinkingBudget, fallbackModels, maxContextTokens);
60+
}
61+
62+
public RoleAgentConfig withMaxContextTokens(int maxContextTokens) {
63+
return new RoleAgentConfig(
64+
id, role, modelId, thinkingEnabled, thinkingBudget, fallbackModels, maxContextTokens);
65+
}
66+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package ai.javaclaw.agent.config;
2+
3+
import java.util.List;
4+
import java.util.Optional;
5+
import org.springframework.data.jdbc.repository.query.Modifying;
6+
import org.springframework.data.jdbc.repository.query.Query;
7+
import org.springframework.data.repository.ListCrudRepository;
8+
import org.springframework.data.repository.query.Param;
9+
10+
/** Spring Data JDBC repository for role_agent_config table (V36). */
11+
public interface RoleAgentConfigRepository extends ListCrudRepository<RoleAgentConfig, String> {
12+
13+
Optional<RoleAgentConfig> findByRole(String role);
14+
15+
boolean existsByRole(String role);
16+
17+
@Modifying
18+
@Query("DELETE FROM role_agent_config WHERE role = :role")
19+
void deleteByRole(@Param("role") String role);
20+
21+
@Query("SELECT * FROM role_agent_config ORDER BY role")
22+
List<RoleAgentConfig> findAllOrdered();
23+
}

0 commit comments

Comments
 (0)