Compare commits

...

1 Commits

Author SHA1 Message Date
9d686a0757 feat(ms22-p2): add agent status endpoints and chat routing
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
P2-005: Agent status endpoints
- GET /api/agents/status — list all user's agents with status
- GET /api/agents/:id/status — single agent status

P2-006: Agent chat routing
- Add optional `agent` parameter to chat requests
- Validate agent exists and is active for user
- Pass agent personality/model config to OpenClaw

Task: MS22-P2-005, MS22-P2-006

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:52:32 -06:00
6 changed files with 116 additions and 10 deletions

View File

@@ -99,7 +99,8 @@ export class ChatProxyController {
const upstreamResponse = await this.chatProxyService.proxyChat( const upstreamResponse = await this.chatProxyService.proxyChat(
userId, userId,
body.messages, body.messages,
abortController.signal abortController.signal,
body.agent
); );
const upstreamContentType = upstreamResponse.headers.get("content-type"); const upstreamContentType = upstreamResponse.headers.get("content-type");

View File

@@ -1,5 +1,12 @@
import { Type } from "class-transformer"; import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator"; import {
ArrayMinSize,
IsArray,
IsNotEmpty,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
export interface ChatMessage { export interface ChatMessage {
role: string; role: string;
@@ -22,4 +29,8 @@ export class ChatStreamDto {
@ValidateNested({ each: true }) @ValidateNested({ each: true })
@Type(() => ChatMessageDto) @Type(() => ChatMessageDto)
messages!: ChatMessageDto[]; messages!: ChatMessageDto[];
@IsString({ message: "agent must be a string" })
@IsOptional()
agent?: string;
} }

View File

@@ -2,6 +2,7 @@ import {
BadGatewayException, BadGatewayException,
Injectable, Injectable,
Logger, Logger,
NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from "@nestjs/common"; } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
@@ -18,6 +19,13 @@ interface ContainerConnection {
token: string; token: string;
} }
interface AgentConfig {
name: string;
displayName: string;
personality: string;
primaryModel: string | null;
}
@Injectable() @Injectable()
export class ChatProxyService { export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name); private readonly logger = new Logger(ChatProxyService.name);
@@ -38,21 +46,38 @@ export class ChatProxyService {
async proxyChat( async proxyChat(
userId: string, userId: string,
messages: ChatMessage[], messages: ChatMessage[],
signal?: AbortSignal signal?: AbortSignal,
agentName?: string
): Promise<Response> { ): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId); const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(userId);
// Get agent config if specified
let agentConfig: AgentConfig | null = null;
if (agentName) {
agentConfig = await this.getAgentConfig(userId, agentName);
}
const model = agentConfig?.primaryModel ?? (await this.getPreferredModel(userId));
const requestBody: Record<string, unknown> = {
messages,
model,
stream: true,
};
// Add agent config if available
if (agentConfig) {
requestBody.agent = agentConfig.name;
requestBody.agent_personality = agentConfig.personality;
}
const requestInit: RequestInit = { const requestInit: RequestInit = {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`, Authorization: `Bearer ${gatewayToken}`,
}, },
body: JSON.stringify({ body: JSON.stringify(requestBody),
messages,
model,
stream: true,
}),
}; };
if (signal) { if (signal) {
@@ -170,4 +195,32 @@ export class ChatProxyService {
return null; return null;
} }
} }
private async getAgentConfig(userId: string, agentName: string): Promise<AgentConfig> {
const agent = await this.prisma.userAgent.findUnique({
where: { userId_name: { userId, name: agentName } },
select: {
name: true,
displayName: true,
personality: true,
primaryModel: true,
isActive: true,
},
});
if (!agent) {
throw new NotFoundException(`Agent "${agentName}" not found for user`);
}
if (!agent.isActive) {
throw new NotFoundException(`Agent "${agentName}" is not active`);
}
return {
name: agent.name,
displayName: agent.displayName,
personality: agent.personality,
primaryModel: agent.primaryModel,
};
}
} }

View File

@@ -26,11 +26,21 @@ export class UserAgentController {
return this.userAgentService.findAll(user.id); return this.userAgentService.findAll(user.id);
} }
@Get("status")
getAllStatuses(@CurrentUser() user: AuthUser) {
return this.userAgentService.getAllStatuses(user.id);
}
@Get(":id") @Get(":id")
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) { findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
return this.userAgentService.findOne(user.id, id); return this.userAgentService.findOne(user.id, id);
} }
@Get(":id/status")
getStatus(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
return this.userAgentService.getStatus(user.id, id);
}
@Post() @Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
return this.userAgentService.create(user.id, dto); return this.userAgentService.create(user.id, dto);

View File

@@ -8,6 +8,15 @@ import { PrismaService } from "../prisma/prisma.service";
import { CreateUserAgentDto } from "./dto/create-user-agent.dto"; import { CreateUserAgentDto } from "./dto/create-user-agent.dto";
import { UpdateUserAgentDto } from "./dto/update-user-agent.dto"; import { UpdateUserAgentDto } from "./dto/update-user-agent.dto";
export interface AgentStatusResponse {
id: string;
name: string;
displayName: string;
role: string;
isActive: boolean;
containerStatus?: "running" | "stopped" | "unknown";
}
@Injectable() @Injectable()
export class UserAgentService { export class UserAgentService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -119,4 +128,26 @@ export class UserAgentService {
await this.findOne(userId, id); await this.findOne(userId, id);
return this.prisma.userAgent.delete({ where: { id } }); return this.prisma.userAgent.delete({ where: { id } });
} }
async getStatus(userId: string, id: string): Promise<AgentStatusResponse> {
const agent = await this.findOne(userId, id);
return {
id: agent.id,
name: agent.name,
displayName: agent.displayName,
role: agent.role,
isActive: agent.isActive,
};
}
async getAllStatuses(userId: string): Promise<AgentStatusResponse[]> {
const agents = await this.findAll(userId);
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
displayName: agent.displayName,
role: agent.role,
isActive: agent.isActive,
}));
}
} }

View File

@@ -100,7 +100,7 @@ PRD: `docs/PRD-MS22-P2-AGENT-FLEET.md`
| MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged | | MS22-P2-002 | done | p2-fleet | Seed default agents (jarvis, builder, medic) | TASKS:P2 | api | feat/ms22-p2-agent-seed | P2-001 | P2-004 | orchestrator | 2026-03-04 | 2026-03-04 | 5K | 2K | PR #677 merged |
| MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged | | MS22-P2-003 | done | p2-fleet | Agent template CRUD endpoints (admin) | TASKS:P2 | api | feat/ms22-p2-agent-crud | P2-001 | P2-005 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 5K | PR #678 merged |
| MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged | | MS22-P2-004 | done | p2-fleet | User agent CRUD endpoints | TASKS:P2 | api | feat/ms22-p2-user-agents | P2-002,P2-003 | P2-006 | orchestrator | 2026-03-04 | 2026-03-04 | 15K | 8K | PR #682 merged |
| MS22-P2-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 10K | — | | | MS22-P2-005 | in-progress | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-status | P2-003 | P2-008 | orchestrator | 2026-03-04 | — | 10K | — | |
| MS22-P2-006 | not-started | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | — | — | — | 15K | — | | | MS22-P2-006 | not-started | p2-fleet | Agent chat routing (select agent by name) | TASKS:P2 | api | feat/ms22-p2-agent-routing | P2-004 | P2-007 | — | — | — | 15K | — | |
| MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | — | | | MS22-P2-007 | not-started | p2-fleet | Discord channel → agent routing | TASKS:P2 | api | feat/ms22-p2-discord-router | P2-006 | P2-009 | — | — | — | 15K | — | |
| MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | — | | | MS22-P2-008 | not-started | p2-fleet | Agent list/selector UI in WebUI | TASKS:P2 | web | feat/ms22-p2-agent-ui | P2-005 | — | — | — | — | 15K | — | |