Compare commits

..

1 Commits

Author SHA1 Message Date
dbed1f877f chore(ms22-p2): update docs after P2-004 completion
- Mark P2-004 (User CRUD) as done in TASKS.md
- Update MISSION-MANIFEST milestones and token budget
- Update scratchpad session log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 20:45:59 -06:00
11 changed files with 12 additions and 397 deletions

View File

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

View File

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

View File

@@ -2,7 +2,6 @@ import {
BadGatewayException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -19,13 +18,6 @@ interface ContainerConnection {
token: string;
}
interface AgentConfig {
name: string;
displayName: string;
personality: string;
primaryModel: string | null;
}
@Injectable()
export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name);
@@ -46,38 +38,21 @@ export class ChatProxyService {
async proxyChat(
userId: string,
messages: ChatMessage[],
signal?: AbortSignal,
agentName?: string
signal?: AbortSignal
): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(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 model = await this.getPreferredModel(userId);
const requestInit: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`,
},
body: JSON.stringify(requestBody),
body: JSON.stringify({
messages,
model,
stream: true,
}),
};
if (signal) {
@@ -195,32 +170,4 @@ export class ChatProxyService {
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,21 +26,11 @@ export class UserAgentController {
return this.userAgentService.findAll(user.id);
}
@Get("status")
getAllStatuses(@CurrentUser() user: AuthUser) {
return this.userAgentService.getAllStatuses(user.id);
}
@Get(":id")
findOne(@CurrentUser() user: AuthUser, @Param("id", ParseUUIDPipe) id: string) {
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()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateUserAgentDto) {
return this.userAgentService.create(user.id, dto);

View File

@@ -8,15 +8,6 @@ import { PrismaService } from "../prisma/prisma.service";
import { CreateUserAgentDto } from "./dto/create-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()
export class UserAgentService {
constructor(private readonly prisma: PrismaService) {}
@@ -128,26 +119,4 @@ export class UserAgentService {
await this.findOne(userId, 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

@@ -1,128 +0,0 @@
"use client";
import React from "react";
interface AgentSelectorProps {
selectedAgent?: string | null;
onChange?: (agent: string | null) => void;
disabled?: boolean;
}
const AGENT_CONFIG = {
jarvis: {
displayName: "Jarvis",
role: "Orchestrator",
color: "#3498db",
},
builder: {
displayName: "Builder",
role: "Coding Agent",
color: "#3b82f6",
},
medic: {
displayName: "Medic",
role: "Health Monitor",
color: "#10b981",
},
} as const;
function JarvisIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v4M12 22v-4" />
<path d="M2 12h4M22 12h-4" />
</svg>
);
}
function BuilderIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
);
}
function MedicIcon({ className }: { className?: string }): React.ReactElement {
return (
<svg
className={`w-3 h-3 ${className ?? ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
}
const AGENT_ICONS: Record<string, React.FC<{ className?: string }>> = {
jarvis: JarvisIcon,
builder: BuilderIcon,
medic: MedicIcon,
};
export function AgentSelector({
selectedAgent,
onChange,
disabled,
}: AgentSelectorProps): React.ReactElement {
return (
<div className="flex items-center gap-2">
<span className="text-xs font-medium" style={{ color: "rgb(var(--text-muted))" }}>
Agent
</span>
<div className="flex flex-wrap gap-1">
{Object.entries(AGENT_CONFIG).map(([name, config]) => {
const Icon = AGENT_ICONS[name];
const isSelected = selectedAgent === name;
return (
<button
key={name}
type="button"
onClick={() => onChange?.(isSelected ? null : name)}
disabled={disabled}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg border transition-all text-xs ${
isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
style={{
borderColor: isSelected
? "rgb(var(--accent-primary))"
: "rgb(var(--border-default))",
color: isSelected ? "rgb(var(--accent-primary))" : "rgb(var(--text-primary))",
}}
title={`${config.displayName}${config.role}`}
>
<span
className="rounded-full"
style={{
backgroundColor: config.color,
width: "8px",
height: "8px",
}}
/>
{Icon && <Icon />}
<span className="font-medium">{config.displayName}</span>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -9,7 +9,6 @@ import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState";
import { AgentSelector } from "./AgentSelector";
import type { Message } from "@/hooks/useChat";
export interface ChatRef {
@@ -67,7 +66,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
const [selectedAgent, setSelectedAgent] = useState<string | null>(null);
// Suggestion fill value: controls ChatInput's textarea content
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
@@ -90,7 +88,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
temperature,
maxTokens,
...(initialProjectId !== undefined && { projectId: initialProjectId }),
...(selectedAgent !== null && { agent: selectedAgent }),
});
// Read workspace ID from localStorage (set by auth-context after session check).
@@ -378,13 +375,6 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
}}
>
<div className="mx-auto max-w-4xl px-4 py-4 lg:px-8">
<div className="mb-3">
<AgentSelector
selectedAgent={selectedAgent}
onChange={setSelectedAgent}
disabled={isChatLoading || isStreaming || !user}
/>
</div>
<ChatInput
onSend={handleSendMessage}
disabled={isChatLoading || !user}

View File

@@ -27,7 +27,6 @@ export interface UseChatOptions {
maxTokens?: number;
systemPrompt?: string;
projectId?: string | null;
agent?: string;
onError?: (error: Error) => void;
}
@@ -64,7 +63,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
maxTokens,
systemPrompt,
projectId,
agent,
onError,
} = options;
@@ -79,10 +77,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
const projectIdRef = useRef<string | null>(projectId ?? null);
projectIdRef.current = projectId ?? null;
// Track agent in ref to prevent stale closures
const agentRef = useRef<string | undefined>(agent);
agentRef.current = agent;
// Track messages in ref to prevent stale closures during rapid sends
const messagesRef = useRef<Message[]>(messages);
messagesRef.current = messages;
@@ -215,7 +209,6 @@ export function useChat(options: UseChatOptions = {}): UseChatReturn {
...(temperature !== undefined && { temperature }),
...(maxTokens !== undefined && { maxTokens }),
...(systemPrompt !== undefined && { systemPrompt }),
...(agentRef.current && { agent: agentRef.current }),
};
const controller = new AbortController();

View File

@@ -1,125 +0,0 @@
/**
* Agent API client
* Handles agent-related API interactions
*/
import { apiGet, apiPost, apiPatch, apiDelete } from "./client";
export interface AgentStatus {
id: string;
name: string;
displayName: string;
role: string;
isActive: boolean;
containerStatus?: "running" | "stopped" | "unknown";
}
export interface UserAgent {
id: string;
userId: string;
templateId: string | null;
name: string;
displayName: string;
role: string;
personality: string;
primaryModel: string | null;
fallbackModels: string[];
toolPermissions: string[];
discordChannel: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface CreateUserAgentRequest {
templateId?: string;
name: string;
displayName: string;
role: string;
personality: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
export interface UpdateUserAgentRequest {
name?: string;
displayName?: string;
role?: string;
personality?: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
export interface UpdateUserAgentRequest {
name?: string;
displayName?: string;
role?: string;
personality?: string;
primaryModel?: string;
fallbackModels?: string[];
toolPermissions?: string[];
discordChannel?: string;
isActive?: boolean;
}
/**
* Get all user's agents
*/
export async function getAgents(): Promise<UserAgent[]> {
return apiGet<UserAgent[]>("/api/agents");
}
/**
* Get all agent statuses
*/
export async function getAgentStatuses(): Promise<AgentStatus[]> {
return apiGet<AgentStatus[]>("/api/agents/status");
}
/**
* Get a single agent by ID
*/
export async function getAgent(id: string): Promise<UserAgent> {
return apiGet<UserAgent>(`/api/agents/${id}`);
}
/**
* Get a single agent's status
*/
export async function getAgentStatus(id: string): Promise<AgentStatus> {
return apiGet<AgentStatus>(`/api/agents/${id}/status`);
}
/**
* Create a new custom agent
*/
export async function createAgent(data: CreateUserAgentRequest): Promise<UserAgent> {
return apiPost<UserAgent>("/api/agents", data);
}
/**
* Create an agent from a template
*/
export async function createAgentFromTemplate(templateId: string): Promise<UserAgent> {
return apiPost<UserAgent>(`/api/agents/from-template/${templateId}`, {});
}
/**
* Update an agent
*/
export async function updateAgent(id: string, data: UpdateUserAgentRequest): Promise<UserAgent> {
return apiPatch<UserAgent>(`/api/agents/${id}`, data);
}
/**
* Delete an agent
*/
export async function deleteAgent(id: string): Promise<void> {
await apiDelete(`/api/agents/${id}`);
}

View File

@@ -18,7 +18,6 @@ export interface ChatRequest {
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
agent?: string;
}
export interface ChatResponse {
@@ -118,11 +117,7 @@ export function streamGuestChat(
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
body: JSON.stringify({ messages: request.messages, stream: true }),
signal: signal ?? null,
});
@@ -274,11 +269,7 @@ export function streamChatMessage(
"X-CSRF-Token": csrfToken,
},
credentials: "include",
body: JSON.stringify({
messages: request.messages,
stream: true,
...(request.agent && { agent: request.agent }),
}),
body: JSON.stringify({ messages: request.messages, stream: true }),
signal: signal ?? null,
});

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-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-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-005 | not-started | p2-fleet | Agent status endpoints | TASKS:P2 | api | feat/ms22-p2-agent-api | P2-003 | P2-008 | — | — | — | 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-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 | — | |