From fa7ac0fe1a02fa5321ff7f295d31f883bec9382c Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Sat, 31 Jan 2026 05:18:44 -0600 Subject: [PATCH] feat: task-aware agent routing engine (#16) - Add AgentRoutingConfig types with RoutingRule, RoutingMatchCriteria, RoutingResult - Add DEFAULT_ROUTING_CONFIG with sensible defaults (code/bug/docs/review rules) - Create AgentRoutingService with resolveAgent() and getFallback() methods - First-match-wins rule evaluation with type, priority, project, minSubtasks criteria - Array support for match criteria (e.g., type: ['code', 'bug']) - Routing API: POST /agents/route, GET/PUT /agents/routing - Integrate routing into ClawdbotAgentService.startAgent() for 'auto' agent selection - Add agent field to Task, CreateTaskInput, UpdateTaskInput, TaskSummary schemas - Settings UI: routing rules section in Agents tab with add/edit/remove/reorder - Agent Panel: show routing recommendation when starting agent - 17 unit tests for routing service (all passing) - Full typecheck clean across shared, server, and web packages --- .../__tests__/agent-routing-service.test.ts | 322 +++++++++ server/src/routes/agent-routing.ts | 147 ++++ server/src/routes/v1/index.ts | 2 + server/src/services/agent-routing-service.ts | 224 +++++++ server/src/services/clawdbot-agent-service.ts | 18 +- shared/src/types/config.types.ts | 128 +++- shared/src/types/task.types.ts | 6 + .../components/settings/tabs/AgentsTab.tsx | 629 +++++++++++++++++- web/src/components/task/AgentPanel.tsx | 489 +++++++------- web/src/hooks/useRouting.ts | 53 ++ web/src/lib/api/agent.ts | 48 +- 11 files changed, 1804 insertions(+), 262 deletions(-) create mode 100644 server/src/__tests__/agent-routing-service.test.ts create mode 100644 server/src/routes/agent-routing.ts create mode 100644 server/src/services/agent-routing-service.ts create mode 100644 web/src/hooks/useRouting.ts diff --git a/server/src/__tests__/agent-routing-service.test.ts b/server/src/__tests__/agent-routing-service.test.ts new file mode 100644 index 00000000..dd2a95d3 --- /dev/null +++ b/server/src/__tests__/agent-routing-service.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentRoutingService } from '../services/agent-routing-service'; +import type { AgentRoutingConfig, AppConfig, Task } from '@veritas-kanban/shared'; + +// Mock ConfigService +const mockGetConfig = vi.fn(); +const mockSaveConfig = vi.fn(); + +vi.mock('../services/config-service.js', () => { + return { + ConfigService: class MockConfigService { + getConfig = mockGetConfig; + saveConfig = mockSaveConfig; + }, + }; +}); + +const BASE_CONFIG: AppConfig = { + repos: [], + agents: [ + { type: 'claude-code', name: 'Claude Code', command: 'claude', args: [], enabled: true }, + { type: 'amp', name: 'Amp', command: 'amp', args: [], enabled: true }, + { type: 'copilot', name: 'GitHub Copilot', command: 'copilot', args: [], enabled: true }, + { type: 'gemini', name: 'Gemini CLI', command: 'gemini', args: [], enabled: false }, + ], + defaultAgent: 'claude-code', + agentRouting: { + enabled: true, + rules: [ + { + id: 'code-high', + name: 'High-priority code', + match: { type: 'code', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'code-default', + name: 'Code tasks', + match: { type: 'code' }, + agent: 'claude-code', + model: 'sonnet', + fallback: 'copilot', + enabled: true, + }, + { + id: 'docs', + name: 'Documentation', + match: { type: 'docs' }, + agent: 'claude-code', + model: 'haiku', + enabled: true, + }, + { + id: 'disabled-rule', + name: 'Disabled rule', + match: { type: 'feature' }, + agent: 'amp', + enabled: false, + }, + ], + defaultAgent: 'claude-code', + defaultModel: 'sonnet', + fallbackOnFailure: true, + maxRetries: 1, + }, +}; + +describe('AgentRoutingService', () => { + let service: AgentRoutingService; + + beforeEach(() => { + mockGetConfig.mockResolvedValue(structuredClone(BASE_CONFIG)); + mockSaveConfig.mockResolvedValue(undefined); + service = new AgentRoutingService(); + }); + + describe('resolveAgent', () => { + it('matches high-priority code task to first rule', async () => { + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('opus'); + expect(result.fallback).toBe('amp'); + expect(result.rule).toBe('code-high'); + expect(result.reason).toContain('High-priority code'); + }); + + it('matches medium-priority code task to second rule', async () => { + const result = await service.resolveAgent({ + type: 'code', + priority: 'medium', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.fallback).toBe('copilot'); + expect(result.rule).toBe('code-default'); + }); + + it('matches docs to docs rule', async () => { + const result = await service.resolveAgent({ + type: 'docs', + priority: 'low', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('haiku'); + expect(result.rule).toBe('docs'); + }); + + it('skips disabled rules', async () => { + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + }); + + // disabled-rule matches feature but is disabled, so falls through to default + expect(result.rule).toBeUndefined(); + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.reason).toContain('No routing rules matched'); + }); + + it('falls back to default when no rules match', async () => { + const result = await service.resolveAgent({ + type: 'design', + priority: 'low', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.model).toBe('sonnet'); + expect(result.rule).toBeUndefined(); + expect(result.reason).toContain('No routing rules matched'); + }); + + it('returns default agent when routing is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.enabled = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + expect(result.agent).toBe('claude-code'); + expect(result.reason).toContain('Routing disabled'); + }); + + it('skips rules where agent is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + // Disable claude-code so the first two rules are skipped + config.agents[0].enabled = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'code', + priority: 'high', + }); + + // Both code rules point to claude-code which is disabled — falls to default + // But default is also claude-code (disabled), so it returns the config default anyway + expect(result.reason).toContain('No routing rules matched'); + }); + + it('matches array criteria', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'multi-type', + name: 'Multiple types', + match: { type: ['bug', 'hotfix'], priority: ['high', 'medium'] }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'bug', + priority: 'medium', + }); + expect(result.agent).toBe('amp'); + expect(result.rule).toBe('multi-type'); + }); + + it('matches minSubtasks criteria', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'complex', + name: 'Complex tasks', + match: { minSubtasks: 5 }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + subtasks: Array.from({ length: 6 }, (_, i) => ({ + id: `s${i}`, + title: `Sub ${i}`, + completed: false, + created: new Date().toISOString(), + })), + }); + expect(result.agent).toBe('amp'); + expect(result.rule).toBe('complex'); + }); + + it('does NOT match when subtasks below threshold', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.rules = [ + { + id: 'complex', + name: 'Complex tasks', + match: { minSubtasks: 5 }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + + const result = await service.resolveAgent({ + type: 'feature', + priority: 'medium', + subtasks: [ + { id: 's1', title: 'Sub 1', completed: false, created: new Date().toISOString() }, + ], + }); + expect(result.rule).toBeUndefined(); // No match + }); + }); + + describe('getFallback', () => { + it('returns fallback agent from matched rule', async () => { + const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code'); + + expect(result).not.toBeNull(); + expect(result!.agent).toBe('amp'); + expect(result!.reason).toContain('Fallback'); + }); + + it('returns null when fallback is disabled', async () => { + const config = structuredClone(BASE_CONFIG); + config.agentRouting!.fallbackOnFailure = false; + mockGetConfig.mockResolvedValue(config); + + const result = await service.getFallback({ type: 'code', priority: 'high' }, 'claude-code'); + expect(result).toBeNull(); + }); + + it('returns default agent as fallback when no specific fallback', async () => { + const result = await service.getFallback( + { type: 'docs', priority: 'low' }, + 'claude-code' // docs rule has no fallback, and default is claude-code (same) + ); + // claude-code === failedAgent, so no fallback + expect(result).toBeNull(); + }); + + it('returns default agent when it differs from failed', async () => { + const result = await service.getFallback( + { type: 'docs', priority: 'low' }, + 'amp' // Failed agent is amp, default is claude-code → valid fallback + ); + expect(result).not.toBeNull(); + expect(result!.agent).toBe('claude-code'); + }); + }); + + describe('updateRoutingConfig', () => { + it('saves valid config', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 0, + }; + + await service.updateRoutingConfig(newConfig); + expect(mockSaveConfig).toHaveBeenCalled(); + }); + + it('rejects duplicate rule IDs', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [ + { id: 'dup', name: 'A', match: {}, agent: 'amp', enabled: true }, + { id: 'dup', name: 'B', match: {}, agent: 'amp', enabled: true }, + ], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 0, + }; + + await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('unique'); + }); + + it('rejects maxRetries > 3', async () => { + const newConfig: AgentRoutingConfig = { + enabled: true, + rules: [], + defaultAgent: 'amp', + fallbackOnFailure: false, + maxRetries: 5, + }; + + await expect(service.updateRoutingConfig(newConfig)).rejects.toThrow('maxRetries'); + }); + }); +}); diff --git a/server/src/routes/agent-routing.ts b/server/src/routes/agent-routing.ts new file mode 100644 index 00000000..42dcbb95 --- /dev/null +++ b/server/src/routes/agent-routing.ts @@ -0,0 +1,147 @@ +/** + * Agent Routing API Routes + * + * POST /api/agents/route — Resolve the best agent for a task + * GET /api/agents/routing — Get current routing configuration + * PUT /api/agents/routing — Update routing configuration + */ + +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { getAgentRoutingService } from '../services/agent-routing-service.js'; +import { getTaskService } from '../services/task-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; + +const router: RouterType = Router(); + +// ─── Validation Schemas ────────────────────────────────────────── + +const routeByTaskIdSchema = z.object({ + taskId: z.string().min(1), +}); + +const routeByMetadataSchema = z.object({ + type: z.string().optional(), + priority: z.enum(['low', 'medium', 'high']).optional(), + project: z.string().optional(), + subtaskCount: z.number().int().nonnegative().optional(), +}); + +const routingMatchSchema = z.object({ + type: z.union([z.string(), z.array(z.string())]).optional(), + priority: z + .union([z.enum(['low', 'medium', 'high']), z.array(z.enum(['low', 'medium', 'high']))]) + .optional(), + project: z.union([z.string(), z.array(z.string())]).optional(), + minSubtasks: z.number().int().nonnegative().optional(), +}); + +const routingRuleSchema = z.object({ + id: z.string().min(1).max(50), + name: z.string().min(1).max(200), + match: routingMatchSchema, + agent: z.string().min(1).max(50), + model: z.string().max(50).optional(), + fallback: z.string().max(50).optional(), + enabled: z.boolean(), +}); + +const routingConfigSchema = z.object({ + enabled: z.boolean(), + rules: z.array(routingRuleSchema), + defaultAgent: z.string().min(1).max(50), + defaultModel: z.string().max(50).optional(), + fallbackOnFailure: z.boolean(), + maxRetries: z.number().int().min(0).max(3), +}); + +// ─── Routes ────────────────────────────────────────────────────── + +/** + * POST /api/agents/route + * + * Resolve the best agent for a task. Accepts either: + * - { taskId: "..." } to look up an existing task + * - { type, priority, project, subtaskCount } for ad-hoc routing + */ +router.post( + '/route', + asyncHandler(async (req, res) => { + const routing = getAgentRoutingService(); + + // Try taskId first + const taskIdParse = routeByTaskIdSchema.safeParse(req.body); + if (taskIdParse.success) { + const taskService = getTaskService(); + const task = await taskService.getTask(taskIdParse.data.taskId); + if (!task) { + throw new NotFoundError('Task not found'); + } + const result = await routing.resolveAgent(task); + return res.json(result); + } + + // Fall back to metadata + const metaParse = routeByMetadataSchema.safeParse(req.body); + if (metaParse.success) { + const { type, priority, project, subtaskCount } = metaParse.data; + const result = await routing.resolveAgent({ + type: type || 'feature', + priority: priority || 'medium', + project, + subtasks: subtaskCount + ? Array.from({ length: subtaskCount }, (_, i) => ({ + id: `stub_${i}`, + title: '', + completed: false, + created: new Date().toISOString(), + })) + : undefined, + }); + return res.json(result); + } + + throw new ValidationError('Provide either { taskId } or { type, priority, ... }'); + }) +); + +/** + * GET /api/agents/routing + * + * Get the current routing configuration. + */ +router.get( + '/routing', + asyncHandler(async (_req, res) => { + const routing = getAgentRoutingService(); + const config = await routing.getRoutingConfig(); + res.json(config); + }) +); + +/** + * PUT /api/agents/routing + * + * Replace the entire routing configuration. + */ +router.put( + '/routing', + asyncHandler(async (req, res) => { + let parsed; + try { + parsed = routingConfigSchema.parse(req.body); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError('Invalid routing config', error.errors); + } + throw error; + } + + const routing = getAgentRoutingService(); + const updated = await routing.updateRoutingConfig(parsed); + res.json(updated); + }) +); + +export { router as agentRoutingRoutes }; diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 5ffe4c60..89de729f 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -28,6 +28,7 @@ import attachmentRoutes from '../attachments.js'; // Feature routes import { configRoutes } from '../config.js'; import { agentRoutes } from '../agents.js'; +import { agentRoutingRoutes } from '../agent-routing.js'; import { diffRoutes } from '../diff.js'; import { automationRoutes } from '../automation.js'; import { summaryRoutes } from '../summary.js'; @@ -85,6 +86,7 @@ v1Router.use( // ── Feature routes ─────────────────────────────────────────── v1Router.use('/config', configRoutes); +v1Router.use('/agents', agentRoutingRoutes); // Must be before agentRoutes (/:taskId would match "route"/"routing") v1Router.use('/agents', agentRoutes); v1Router.use('/diff', diffRoutes); v1Router.use('/automation', automationRoutes); diff --git a/server/src/services/agent-routing-service.ts b/server/src/services/agent-routing-service.ts new file mode 100644 index 00000000..af116e7a --- /dev/null +++ b/server/src/services/agent-routing-service.ts @@ -0,0 +1,224 @@ +/** + * AgentRoutingService - Task-aware agent/model routing + * + * Matches task metadata (type, priority, project, complexity) against + * user-configured routing rules to select the optimal agent and model. + * + * Rules are evaluated in order — first match wins. + * Falls back to the configured default when no rules match. + */ + +import { ConfigService } from './config-service.js'; +import { + DEFAULT_ROUTING_CONFIG, + type AgentRoutingConfig, + type RoutingRule, + type RoutingResult, + type RoutingMatchCriteria, +} from '@veritas-kanban/shared'; +import type { Task, AgentType, TaskPriority } from '@veritas-kanban/shared'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('agent-routing'); + +export class AgentRoutingService { + private configService: ConfigService; + + constructor(configService?: ConfigService) { + this.configService = configService || new ConfigService(); + } + + /** + * Resolve the best agent for a given task. + * + * @param task - Full task object (or partial with type/priority/project/subtasks) + * @returns RoutingResult with the selected agent, optional model, fallback, and reasoning + */ + async resolveAgent( + task: Pick + ): Promise { + const config = await this.configService.getConfig(); + const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG; + + // If routing is disabled, return the global default + if (!routing.enabled) { + return { + agent: routing.defaultAgent || config.defaultAgent, + model: routing.defaultModel, + reason: 'Routing disabled — using default agent', + }; + } + + // Evaluate rules in order (first match wins) + for (const rule of routing.rules) { + if (!rule.enabled) continue; + + if (this.matchesRule(task, rule.match)) { + // Verify the agent is actually configured and enabled + const agentConfig = config.agents.find((a) => a.type === rule.agent); + if (!agentConfig?.enabled) { + log.warn(`Rule "${rule.name}" matched but agent "${rule.agent}" is disabled — skipping`); + continue; + } + + log.info( + `Task [type=${task.type}, priority=${task.priority}] matched rule "${rule.name}" → ${rule.agent}${rule.model ? ` (${rule.model})` : ''}` + ); + return { + agent: rule.agent, + model: rule.model, + fallback: rule.fallback, + rule: rule.id, + reason: `Matched rule: ${rule.name}`, + }; + } + } + + // No rule matched — use defaults + log.info( + `Task [type=${task.type}, priority=${task.priority}] — no rules matched, using default: ${routing.defaultAgent}` + ); + return { + agent: routing.defaultAgent || config.defaultAgent, + model: routing.defaultModel, + reason: 'No routing rules matched — using default agent', + }; + } + + /** + * Get the fallback agent for a given primary agent. + * Used when an agent fails and `fallbackOnFailure` is enabled. + */ + async getFallback( + task: Pick, + failedAgent: AgentType + ): Promise { + const config = await this.configService.getConfig(); + const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG; + + if (!routing.fallbackOnFailure) { + return null; + } + + // Find the rule that originally matched (to get its fallback) + for (const rule of routing.rules) { + if (!rule.enabled) continue; + if (rule.agent !== failedAgent) continue; + if (!rule.fallback) continue; + if (!this.matchesRule(task, rule.match)) continue; + + const fallbackConfig = config.agents.find((a) => a.type === rule.fallback); + if (!fallbackConfig?.enabled) { + log.warn(`Fallback agent "${rule.fallback}" for rule "${rule.name}" is disabled`); + continue; + } + + log.info(`Falling back from ${failedAgent} → ${rule.fallback} (rule: ${rule.name})`); + return { + agent: rule.fallback, + rule: rule.id, + reason: `Fallback: ${failedAgent} failed → ${rule.fallback} (rule: ${rule.name})`, + }; + } + + // No specific fallback found — try default if it's different from failed + const defaultAgent = routing.defaultAgent || config.defaultAgent; + if (defaultAgent !== failedAgent) { + const defaultConfig = config.agents.find((a) => a.type === defaultAgent); + if (defaultConfig?.enabled) { + return { + agent: defaultAgent, + model: routing.defaultModel, + reason: `Fallback: ${failedAgent} failed → default agent (${defaultAgent})`, + }; + } + } + + return null; + } + + /** + * Get the current routing config (for UI display). + */ + async getRoutingConfig(): Promise { + const config = await this.configService.getConfig(); + return config.agentRouting || DEFAULT_ROUTING_CONFIG; + } + + /** + * Update routing config. + */ + async updateRoutingConfig(routing: AgentRoutingConfig): Promise { + // Validate rule IDs are unique + const ids = routing.rules.map((r) => r.id); + const uniqueIds = new Set(ids); + if (ids.length !== uniqueIds.size) { + throw new Error('Routing rule IDs must be unique'); + } + + // Validate maxRetries range + if (routing.maxRetries < 0 || routing.maxRetries > 3) { + throw new Error('maxRetries must be between 0 and 3'); + } + + const config = await this.configService.getConfig(); + await this.configService.saveConfig({ ...config, agentRouting: routing }); + return routing; + } + + // ─── Private helpers ─────────────────────────────────────────── + + /** + * Check if a task matches a rule's criteria. + * All specified criteria must match (AND logic). + * Unspecified criteria are ignored (wildcard). + */ + private matchesRule( + task: Pick, + match: RoutingMatchCriteria + ): boolean { + // Type check + if (match.type !== undefined) { + if (!this.matchesValue(task.type, match.type)) return false; + } + + // Priority check + if (match.priority !== undefined) { + if (!this.matchesValue(task.priority, match.priority)) return false; + } + + // Project check + if (match.project !== undefined) { + if (!task.project) return false; + if (!this.matchesValue(task.project, match.project)) return false; + } + + // Complexity (subtask count) + if (match.minSubtasks !== undefined) { + const subtaskCount = task.subtasks?.length ?? 0; + if (subtaskCount < match.minSubtasks) return false; + } + + return true; + } + + /** + * Check if a value matches a single value or array of acceptable values. + */ + private matchesValue(actual: T, expected: T | T[]): boolean { + if (Array.isArray(expected)) { + return expected.includes(actual); + } + return actual === expected; + } +} + +// Singleton +let _instance: AgentRoutingService | null = null; + +export function getAgentRoutingService(): AgentRoutingService { + if (!_instance) { + _instance = new AgentRoutingService(); + } + return _instance; +} diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index bac67a6b..40513bdf 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -16,6 +16,7 @@ import fs from 'fs/promises'; import path from 'path'; import { ConfigService } from './config-service.js'; import { TaskService } from './task-service.js'; +import { getAgentRoutingService } from './agent-routing-service.js'; import { getBreaker } from './circuit-registry.js'; import type { Task, AgentType, TaskAttempt, AttemptStatus } from '@veritas-kanban/shared'; import { createLogger } from '../lib/logger.js'; @@ -95,9 +96,22 @@ export class ClawdbotAgentService { throw new Error('An agent is already running for this task'); } - // Get agent config + // Get agent config — use routing engine when agent is "auto" or not specified const config = await this.configService.getConfig(); - const agent = agentType || config.defaultAgent; + let agent: AgentType; + let routingReason: string | undefined; + + if (!agentType || agentType === 'auto') { + const routing = getAgentRoutingService(); + const result = await routing.resolveAgent(task); + agent = result.agent; + routingReason = result.reason; + log.info( + `[ClawdbotAgent] Routing resolved agent for task ${taskId}: ${agent} (${routingReason})` + ); + } else { + agent = agentType; + } // Create attempt const attemptId = `attempt_${nanoid(8)}`; diff --git a/shared/src/types/config.types.ts b/shared/src/types/config.types.ts index e5fbeab1..5c6eccfc 100644 --- a/shared/src/types/config.types.ts +++ b/shared/src/types/config.types.ts @@ -4,8 +4,8 @@ import type { AgentType, TaskPriority } from './task.types.js'; import type { TelemetryConfig } from './telemetry.types.js'; export interface DevServerConfig { - command: string; // e.g., "pnpm dev" or "npm run dev" - port?: number; // Expected port (auto-detected if not specified) + command: string; // e.g., "pnpm dev" or "npm run dev" + port?: number; // Expected port (auto-detected if not specified) readyPattern?: string; // Regex pattern to detect when server is ready } @@ -24,10 +24,106 @@ export interface AgentConfig { enabled: boolean; } +// ============ Agent Routing Types ============ + +/** Criteria for matching a task to a routing rule */ +export interface RoutingMatchCriteria { + type?: string | string[]; // Task type(s) — e.g. "code", "bug", "docs" + priority?: TaskPriority | TaskPriority[]; // Task priority(ies) + project?: string | string[]; // Project slug(s) + /** Min subtask count to consider "complex" */ + minSubtasks?: number; +} + +/** A single routing rule: match criteria → agent selection */ +export interface RoutingRule { + id: string; // Unique rule ID for CRUD + name: string; // Human-readable name + match: RoutingMatchCriteria; // Conditions to match + agent: AgentType; // Primary agent to use + model?: string; // Optional model override (e.g. "opus", "sonnet") + fallback?: AgentType; // Fallback agent if primary fails + enabled: boolean; // Can disable without deleting +} + +/** Top-level routing configuration */ +export interface AgentRoutingConfig { + enabled: boolean; // Master toggle for routing engine + rules: RoutingRule[]; // Ordered list — first match wins + defaultAgent: AgentType; // Fallback when no rules match + defaultModel?: string; // Default model for the default agent + fallbackOnFailure: boolean; // Auto-retry with fallback on failure + maxRetries: number; // Max retries before giving up (0-3) +} + +/** Result from the routing engine */ +export interface RoutingResult { + agent: AgentType; + model?: string; + fallback?: AgentType; + rule?: string; // ID of matched rule (undefined = default) + reason: string; // Human-readable explanation +} + +/** Default routing config */ +export const DEFAULT_ROUTING_CONFIG: AgentRoutingConfig = { + enabled: true, + rules: [ + { + id: 'code-high', + name: 'High-priority code → Claude Code (Opus)', + match: { type: 'code', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'code-default', + name: 'Code tasks → Claude Code (Sonnet)', + match: { type: 'code' }, + agent: 'claude-code', + model: 'sonnet', + fallback: 'copilot', + enabled: true, + }, + { + id: 'bug-high', + name: 'High-priority bugs → Claude Code (Opus)', + match: { type: 'bug', priority: 'high' }, + agent: 'claude-code', + model: 'opus', + fallback: 'amp', + enabled: true, + }, + { + id: 'docs', + name: 'Documentation → Claude Code (Haiku)', + match: { type: 'docs' }, + agent: 'claude-code', + model: 'haiku', + enabled: true, + }, + { + id: 'review', + name: 'Code review → Claude Code (Opus)', + match: { type: 'review' }, + agent: 'claude-code', + model: 'opus', + enabled: true, + }, + ], + defaultAgent: 'claude-code', + defaultModel: 'sonnet', + fallbackOnFailure: true, + maxRetries: 1, +}; + export interface AppConfig { repos: RepoConfig[]; agents: AgentConfig[]; defaultAgent: AgentType; + agentRouting?: AgentRoutingConfig; telemetry?: TelemetryConfig; features?: FeatureSettings; } @@ -52,16 +148,16 @@ export interface TaskBehaviorSettings { enableSubtaskAutoComplete: boolean; enableDependencies: boolean; enableAttachments: boolean; - attachmentMaxFileSize: number; // bytes + attachmentMaxFileSize: number; // bytes attachmentMaxPerTask: number; - attachmentMaxTotalSize: number; // bytes + attachmentMaxTotalSize: number; // bytes enableComments: boolean; defaultPriority: TaskPriority; } /** Agent & git settings */ export interface AgentBehaviorSettings { - timeoutMinutes: number; // 5-480 + timeoutMinutes: number; // 5-480 autoCommitOnComplete: boolean; autoCleanupWorktrees: boolean; enablePreview: boolean; @@ -70,7 +166,7 @@ export interface AgentBehaviorSettings { /** Telemetry & activity settings */ export interface TelemetryFeatureSettings { enabled: boolean; - retentionDays: number; // 7-365 + retentionDays: number; // 7-365 enableTraces: boolean; enableActivityTracking: boolean; } @@ -81,8 +177,8 @@ export interface NotificationSettings { onTaskComplete: boolean; onAgentFailure: boolean; onReviewNeeded: boolean; - channel: string; // Teams channel ID - webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery + channel: string; // Teams channel ID + webhookUrl?: string; // Optional: Teams webhook URL for immediate delivery } /** Archive settings */ @@ -94,9 +190,9 @@ export interface ArchiveSettings { /** Budget tracking settings */ export interface BudgetSettings { enabled: boolean; - monthlyTokenLimit: number; // Monthly token budget (0 = no limit) - monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit) - warningThreshold: number; // Percentage threshold for warning (0-100, default 80) + monthlyTokenLimit: number; // Monthly token budget (0 = no limit) + monthlyCostLimit: number; // Monthly cost budget in dollars (0 = no limit) + warningThreshold: number; // Percentage threshold for warning (0-100, default 80) } /** All feature settings combined */ @@ -127,9 +223,9 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { enableSubtaskAutoComplete: true, enableDependencies: true, enableAttachments: true, - attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB + attachmentMaxFileSize: 10 * 1024 * 1024, // 10MB attachmentMaxPerTask: 20, - attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB + attachmentMaxTotalSize: 50 * 1024 * 1024, // 50MB enableComments: true, defaultPriority: 'medium', }, @@ -158,8 +254,8 @@ export const DEFAULT_FEATURE_SETTINGS: FeatureSettings = { }, budget: { enabled: true, - monthlyTokenLimit: 0, // 0 = no limit - monthlyCostLimit: 0, // 0 = no limit (dollars) - warningThreshold: 80, // Warn at 80% of budget + monthlyTokenLimit: 0, // 0 = no limit + monthlyCostLimit: 0, // 0 = no limit (dollars) + warningThreshold: 80, // Warn at 80% of budget }, }; diff --git a/shared/src/types/task.types.ts b/shared/src/types/task.types.ts index 9fe09776..23c9fea2 100644 --- a/shared/src/types/task.types.ts +++ b/shared/src/types/task.types.ts @@ -125,6 +125,9 @@ export interface Task { created: string; updated: string; + // Agent assignment — "auto" uses routing engine, or a specific agent slug + agent?: AgentType | 'auto'; + // Code task specific git?: TaskGit; @@ -196,6 +199,7 @@ export interface CreateTaskInput { priority?: TaskPriority; project?: string; sprint?: string; + agent?: AgentType | 'auto'; // Pre-assign an agent (or "auto" for routing engine) subtasks?: Subtask[]; // Can be provided when creating from a template blockedBy?: string[]; // Can be provided when creating from a blueprint } @@ -208,6 +212,7 @@ export interface UpdateTaskInput { priority?: TaskPriority; project?: string; sprint?: string; + agent?: AgentType | 'auto'; git?: Partial; attempt?: TaskAttempt; reviewComments?: ReviewComment[]; @@ -247,6 +252,7 @@ export interface TaskSummary { type: TaskType; project?: string; sprint?: string; + agent?: AgentType | 'auto'; created: string; updated: string; subtasks?: Subtask[]; diff --git a/web/src/components/settings/tabs/AgentsTab.tsx b/web/src/components/settings/tabs/AgentsTab.tsx index 534c1235..53c56778 100644 --- a/web/src/components/settings/tabs/AgentsTab.tsx +++ b/web/src/components/settings/tabs/AgentsTab.tsx @@ -1,8 +1,16 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { AlertDialog, AlertDialogAction, @@ -16,9 +24,26 @@ import { } from '@/components/ui/alert-dialog'; import { useConfig, useUpdateAgents } from '@/hooks/useConfig'; import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings'; -import { Bot, Plus, Pencil, Trash2, Check, X } from 'lucide-react'; -import type { AgentConfig, AgentType } from '@veritas-kanban/shared'; -import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared'; +import { useRoutingConfig, useUpdateRoutingConfig } from '@/hooks/useRouting'; +import { + Bot, + Plus, + Pencil, + Trash2, + Check, + X, + Route, + ChevronDown, + ChevronUp, + Loader2, +} from 'lucide-react'; +import type { + AgentConfig, + AgentType, + RoutingRule, + AgentRoutingConfig, +} from '@veritas-kanban/shared'; +import { DEFAULT_FEATURE_SETTINGS, DEFAULT_ROUTING_CONFIG } from '@veritas-kanban/shared'; import { cn } from '@/lib/utils'; import { ToggleRow, NumberRow, SectionHeader, SaveIndicator } from '../shared'; @@ -160,6 +185,9 @@ export function AgentsTab() { /> + + {/* Agent Routing Rules */} + ); } @@ -268,6 +296,599 @@ interface AgentFormProps { onCancel: () => void; } +// ============ Routing Rules Section ============ + +interface RoutingRulesSectionProps { + agents: AgentConfig[]; +} + +function RoutingRulesSection({ agents }: RoutingRulesSectionProps) { + const { data: routingConfig, isLoading } = useRoutingConfig(); + const updateRouting = useUpdateRoutingConfig(); + const [editingRuleId, setEditingRuleId] = useState(null); + const [showAddRule, setShowAddRule] = useState(false); + const [expanded, setExpanded] = useState(true); + + const config = routingConfig || DEFAULT_ROUTING_CONFIG; + const enabledAgents = agents.filter((a) => a.enabled); + + const saveConfig = useCallback( + (updated: AgentRoutingConfig) => { + updateRouting.mutate(updated); + }, + [updateRouting] + ); + + const handleToggleEnabled = () => { + saveConfig({ ...config, enabled: !config.enabled }); + }; + + const handleToggleRule = (ruleId: string) => { + const updated = { + ...config, + rules: config.rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)), + }; + saveConfig(updated); + }; + + const handleAddRule = (rule: RoutingRule) => { + saveConfig({ ...config, rules: [...config.rules, rule] }); + setShowAddRule(false); + }; + + const handleEditRule = (originalId: string, updated: RoutingRule) => { + saveConfig({ + ...config, + rules: config.rules.map((r) => (r.id === originalId ? updated : r)), + }); + setEditingRuleId(null); + }; + + const handleRemoveRule = (ruleId: string) => { + saveConfig({ + ...config, + rules: config.rules.filter((r) => r.id !== ruleId), + }); + }; + + const handleMoveRule = (ruleId: string, direction: 'up' | 'down') => { + const idx = config.rules.findIndex((r) => r.id === ruleId); + if (idx < 0) return; + const newIdx = direction === 'up' ? idx - 1 : idx + 1; + if (newIdx < 0 || newIdx >= config.rules.length) return; + const newRules = [...config.rules]; + [newRules[idx], newRules[newIdx]] = [newRules[newIdx], newRules[idx]]; + saveConfig({ ...config, rules: newRules }); + }; + + const handleDefaultAgentChange = (agent: string) => { + saveConfig({ ...config, defaultAgent: agent as AgentType }); + }; + + const handleDefaultModelChange = (model: string) => { + saveConfig({ ...config, defaultModel: model || undefined }); + }; + + const handleFallbackToggle = () => { + saveConfig({ ...config, fallbackOnFailure: !config.fallbackOnFailure }); + }; + + const handleMaxRetriesChange = (value: number) => { + saveConfig({ ...config, maxRetries: Math.min(3, Math.max(0, value)) }); + }; + + const resetRouting = () => { + saveConfig(DEFAULT_ROUTING_CONFIG); + }; + + if (isLoading) { + return ( +
+ Loading routing config... +
+ ); + } + + return ( +
+
+ +
+ {updateRouting.isPending && } + +
+
+ + {expanded && ( +
+ {/* Rules list */} +
+
+

+ Rules (first match wins) +

+ {!showAddRule && ( + + )} +
+ + {showAddRule && ( + r.id)} + onSubmit={handleAddRule} + onCancel={() => setShowAddRule(false)} + /> + )} + + {config.rules.length === 0 ? ( +
+ No routing rules — all tasks use the default agent. +
+ ) : ( +
+ {config.rules.map((rule, idx) => + editingRuleId === rule.id ? ( + r.id !== rule.id).map((r) => r.id)} + onSubmit={(updated) => handleEditRule(rule.id, updated)} + onCancel={() => setEditingRuleId(null)} + /> + ) : ( + handleToggleRule(rule.id)} + onEdit={() => setEditingRuleId(rule.id)} + onRemove={() => handleRemoveRule(rule.id)} + onMoveUp={() => handleMoveRule(rule.id, 'up')} + onMoveDown={() => handleMoveRule(rule.id, 'down')} + /> + ) + )} +
+ )} +
+ + {/* Default & Fallback settings */} +
+
+

+ Defaults +

+ +
+
+
+
+ +

Used when no rules match

+
+ +
+
+
+ +

+ Model override for the default agent +

+
+ handleDefaultModelChange(e.target.value)} + placeholder="e.g., sonnet" + className="w-[180px] h-8 text-sm" + /> +
+ + +
+
+
+ )} +
+ ); +} + +// ============ Routing Rule Item (display mode) ============ + +interface RoutingRuleItemProps { + rule: RoutingRule; + agents: AgentConfig[]; + isFirst: boolean; + isLast: boolean; + onToggle: () => void; + onEdit: () => void; + onRemove: () => void; + onMoveUp: () => void; + onMoveDown: () => void; +} + +function RoutingRuleItem({ + rule, + agents, + isFirst, + isLast, + onToggle, + onEdit, + onRemove, + onMoveUp, + onMoveDown, +}: RoutingRuleItemProps) { + const agentName = agents.find((a) => a.type === rule.agent)?.name || rule.agent; + const fallbackName = rule.fallback + ? agents.find((a) => a.type === rule.fallback)?.name || rule.fallback + : null; + + const matchLabels: string[] = []; + if (rule.match.type) { + const types = Array.isArray(rule.match.type) ? rule.match.type : [rule.match.type]; + matchLabels.push(`type: ${types.join(', ')}`); + } + if (rule.match.priority) { + const priorities = Array.isArray(rule.match.priority) + ? rule.match.priority + : [rule.match.priority]; + matchLabels.push(`priority: ${priorities.join(', ')}`); + } + if (rule.match.project) { + const projects = Array.isArray(rule.match.project) ? rule.match.project : [rule.match.project]; + matchLabels.push(`project: ${projects.join(', ')}`); + } + if (rule.match.minSubtasks) { + matchLabels.push(`≥${rule.match.minSubtasks} subtasks`); + } + + return ( +
+ {/* Reorder buttons */} +
+ + +
+ + {/* Rule info */} +
+
+ {rule.name} +
+
+ {matchLabels.map((label, i) => ( + + {label} + + ))} + → + + {agentName} + {rule.model ? ` (${rule.model})` : ''} + + {fallbackName && ( + <> + fallback: + + {fallbackName} + + + )} +
+
+ + {/* Actions */} +
+ + + +
+
+ ); +} + +// ============ Routing Rule Form (add/edit mode) ============ + +interface RoutingRuleFormProps { + rule?: RoutingRule; + agents: AgentConfig[]; + existingIds: string[]; + onSubmit: (rule: RoutingRule) => void; + onCancel: () => void; +} + +function RoutingRuleForm({ rule, agents, existingIds, onSubmit, onCancel }: RoutingRuleFormProps) { + const isEditing = !!rule; + const [name, setName] = useState(rule?.name || ''); + const [id, setId] = useState(rule?.id || ''); + const [matchType, setMatchType] = useState( + rule?.match.type + ? Array.isArray(rule.match.type) + ? rule.match.type.join(', ') + : rule.match.type + : '' + ); + const [matchPriority, setMatchPriority] = useState( + rule?.match.priority + ? Array.isArray(rule.match.priority) + ? rule.match.priority.join(', ') + : rule.match.priority + : '' + ); + const [matchProject, setMatchProject] = useState( + rule?.match.project + ? Array.isArray(rule.match.project) + ? rule.match.project.join(', ') + : rule.match.project + : '' + ); + const [minSubtasks, setMinSubtasks] = useState(rule?.match.minSubtasks?.toString() || ''); + const [agent, setAgent] = useState(rule?.agent || agents[0]?.type || ''); + const [model, setModel] = useState(rule?.model || ''); + const [fallback, setFallback] = useState(rule?.fallback || ''); + + const autoId = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const effectiveId = id || autoId; + const isDuplicate = !isEditing && existingIds.includes(effectiveId); + const isValid = name.trim() && effectiveId && agent && !isDuplicate; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!isValid) return; + + const parseList = (val: string): string | string[] | undefined => { + if (!val.trim()) return undefined; + const items = val + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + return items.length === 1 ? items[0] : items.length > 0 ? items : undefined; + }; + + onSubmit({ + id: isEditing ? rule.id : effectiveId, + name: name.trim(), + match: { + type: parseList(matchType), + priority: parseList(matchPriority) as any, + project: parseList(matchProject), + minSubtasks: minSubtasks ? parseInt(minSubtasks, 10) : undefined, + }, + agent: agent as AgentType, + model: model.trim() || undefined, + fallback: (fallback.trim() || undefined) as AgentType | undefined, + enabled: rule?.enabled ?? true, + }); + }; + + return ( +
+
+ + {isEditing ? `Edit Rule: ${rule.name}` : 'Add Routing Rule'} +
+ +
+
+
+ + setName(e.target.value)} + placeholder="e.g., High-priority bugs" + /> +
+
+ + setId(e.target.value)} + placeholder="auto from name" + disabled={isEditing} + className={cn(isDuplicate && 'border-red-500')} + /> +
+
+ + {/* Match criteria */} +
+
+ + setMatchType(e.target.value)} + placeholder="e.g., code, bug" + className="font-mono text-sm" + /> +
+
+ + setMatchPriority(e.target.value)} + placeholder="e.g., high" + className="font-mono text-sm" + /> +
+
+ +
+
+ + setMatchProject(e.target.value)} + placeholder="e.g., rubicon" + className="font-mono text-sm" + /> +
+
+ + setMinSubtasks(e.target.value)} + placeholder="e.g., 5" + className="font-mono text-sm" + min="0" + /> +
+
+ + {/* Agent selection */} +
+
+ + +
+
+ + setModel(e.target.value)} + placeholder="e.g., opus" + className="font-mono text-sm" + /> +
+
+ + +
+
+
+ +
+ + +
+
+ ); +} + +// ============ Agent Form (add/edit mode) ============ + function AgentForm({ agent, existingTypes, onSubmit, onCancel }: AgentFormProps) { const isEditing = !!agent; const [name, setName] = useState(agent?.name || ''); diff --git a/web/src/components/task/AgentPanel.tsx b/web/src/components/task/AgentPanel.tsx index f1b9a747..2fac7bb1 100644 --- a/web/src/components/task/AgentPanel.tsx +++ b/web/src/components/task/AgentPanel.tsx @@ -31,6 +31,7 @@ import { useAgentAttempts, useAgentLog, } from '@/hooks/useAgent'; +import { useResolveAgent } from '@/hooks/useRouting'; import { Play, Square, @@ -68,7 +69,8 @@ export function AgentPanel({ task }: AgentPanelProps) { const { data: agentStatus } = useAgentStatus(task.id); const { outputs, isConnected, isRunning, clearOutputs } = useAgentStream(task.id); const { data: attempts, refetch: refetchAttempts } = useAgentAttempts(task.id); - + const { data: routingResult } = useResolveAgent(task.id); + const startAgent = useStartAgent(); const stopAgent = useStopAgent(); const sendMessage = useSendMessage(); @@ -77,17 +79,17 @@ export function AgentPanel({ task }: AgentPanelProps) { const [message, setMessage] = useState(''); const [autoScroll, setAutoScroll] = useState(true); const [viewingAttemptId, setViewingAttemptId] = useState(null); - + const outputRef = useRef(null); - + // Fetch log for historical attempt const { data: attemptLog, isLoading: isLoadingLog } = useAgentLog( - viewingAttemptId ? task.id : undefined, + viewingAttemptId ? task.id : undefined, viewingAttemptId || undefined ); // Get enabled agents - const enabledAgents = config?.agents.filter(a => a.enabled) || []; + const enabledAgents = config?.agents.filter((a) => a.enabled) || []; const defaultAgent = config?.defaultAgent; // Auto-scroll to bottom @@ -108,14 +110,17 @@ export function AgentPanel({ task }: AgentPanelProps) { const handleStart = () => { clearOutputs(); setViewingAttemptId(null); // Switch back to live view - startAgent.mutate({ - taskId: task.id, - agent: selectedAgent || defaultAgent, - }, { - onSuccess: () => { - refetchAttempts(); + startAgent.mutate( + { + taskId: task.id, + agent: selectedAgent || routingResult?.agent || defaultAgent, + }, + { + onSuccess: () => { + refetchAttempts(); + }, } - }); + ); }; const handleStop = () => { @@ -125,7 +130,7 @@ export function AgentPanel({ task }: AgentPanelProps) { const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); if (!message.trim()) return; - + sendMessage.mutate({ taskId: task.id, message: message.trim(), @@ -160,240 +165,246 @@ export function AgentPanel({ task }: AgentPanelProps) { AI Agent -
- {isConnected ? ( - - ) : ( - - )} - {isAgentRunning && ( - - - Running - - )} -
- - -
- {/* Controls */} -
- {!isAgentRunning ? ( - <> - - - - ) : ( - - - - - - - Stop the agent? - - This will terminate the running agent. The attempt will be marked as failed. - - - - Cancel - - Stop Agent - - - - - )} -
- - {/* Output */} -
- {outputs.length === 0 ? ( -
- - {isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'} -
- ) : ( - outputs.map((output, i) => ( -
- {output.type === 'stdin' && You: } - {sanitizeText(output.content)} -
- )) - )} -
- - {/* Input */} - {isAgentRunning && ( -
- setMessage(e.target.value)} - placeholder="Send a message to the agent..." - className="flex-1 h-8 text-sm" - /> - -
- )} -
- - {/* New Attempt button for completed/failed tasks */} - {task.attempt && ['complete', 'failed'].includes(task.attempt.status) && !isAgentRunning && ( - - )} - - {/* Attempt History */} - {attempts && attempts.length > 0 && ( -
- -
- {attempts.map((attemptId) => { - const isCurrentAttempt = task.attempt?.id === attemptId; - const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete'; - const isViewing = viewingAttemptId === attemptId; - - return ( -
{ - if (isCurrentAttempt && isAgentRunning) { - setViewingAttemptId(null); // Show live output - } else { - setViewingAttemptId(isViewing ? null : attemptId); - } - }} - > - {attemptStatusIcons[attemptStatus as AttemptStatus] || attemptStatusIcons.complete} - {attemptId} - {isCurrentAttempt && ( - Current - )} - {isViewing && !isCurrentAttempt && ( - Viewing - )} -
- ); - })} -
-
- )} - - {/* Historical attempt log viewer */} - {viewingAttemptId && viewingAttemptId !== task.attempt?.id && ( -
-
- - Viewing: {viewingAttemptId} - - -
-
- {isLoadingLog ? ( -
- - Loading log... -
- ) : attemptLog ? ( -
{attemptLog}
+
+ {isConnected ? ( + ) : ( -
No log available
+ + )} + {isAgentRunning && ( + + + Running + )}
- )} - {/* Current attempt info */} - {task.attempt && !viewingAttemptId && ( -
-
- {attemptStatusIcons[task.attempt.status]} - Current: {task.attempt.id} +
+ {/* Controls */} +
+ {!isAgentRunning ? ( + <> + {routingResult && !selectedAgent && ( + + Rec:{' '} + {enabledAgents.find((a) => a.type === routingResult.agent)?.name || + routingResult.agent} + {routingResult.model ? ` (${routingResult.model})` : ''} + + )} + + + + ) : ( + + + + + + + Stop the agent? + + This will terminate the running agent. The attempt will be marked as failed. + + + + Cancel + + Stop Agent + + + + + )}
-
Agent: {task.attempt.agent}
- {task.attempt.started && ( -
Started: {new Date(task.attempt.started).toLocaleString()}
- )} - {task.attempt.ended && ( -
Ended: {new Date(task.attempt.ended).toLocaleString()}
+ + {/* Output */} +
+ {outputs.length === 0 ? ( +
+ + {isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'} +
+ ) : ( + outputs.map((output, i) => ( +
+ {output.type === 'stdin' && You: } + {sanitizeText(output.content)} +
+ )) + )} +
+ + {/* Input */} + {isAgentRunning && ( +
+ setMessage(e.target.value)} + placeholder="Send a message to the agent..." + className="flex-1 h-8 text-sm" + /> + +
)}
- )} + + {/* New Attempt button for completed/failed tasks */} + {task.attempt && + ['complete', 'failed'].includes(task.attempt.status) && + !isAgentRunning && ( + + )} + + {/* Attempt History */} + {attempts && attempts.length > 0 && ( +
+ +
+ {attempts.map((attemptId) => { + const isCurrentAttempt = task.attempt?.id === attemptId; + const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete'; + const isViewing = viewingAttemptId === attemptId; + + return ( +
{ + if (isCurrentAttempt && isAgentRunning) { + setViewingAttemptId(null); // Show live output + } else { + setViewingAttemptId(isViewing ? null : attemptId); + } + }} + > + {attemptStatusIcons[attemptStatus as AttemptStatus] || + attemptStatusIcons.complete} + {attemptId} + {isCurrentAttempt && ( + + Current + + )} + {isViewing && !isCurrentAttempt && ( + + Viewing + + )} +
+ ); + })} +
+
+ )} + + {/* Historical attempt log viewer */} + {viewingAttemptId && viewingAttemptId !== task.attempt?.id && ( +
+
+ Viewing: {viewingAttemptId} + +
+
+ {isLoadingLog ? ( +
+ + Loading log... +
+ ) : attemptLog ? ( +
{attemptLog}
+ ) : ( +
No log available
+ )} +
+
+ )} + + {/* Current attempt info */} + {task.attempt && !viewingAttemptId && ( +
+
+ {attemptStatusIcons[task.attempt.status]} + Current: {task.attempt.id} +
+
Agent: {task.attempt.agent}
+ {task.attempt.started && ( +
Started: {new Date(task.attempt.started).toLocaleString()}
+ )} + {task.attempt.ended && ( +
Ended: {new Date(task.attempt.ended).toLocaleString()}
+ )} +
+ )}
); diff --git a/web/src/hooks/useRouting.ts b/web/src/hooks/useRouting.ts new file mode 100644 index 00000000..fc983085 --- /dev/null +++ b/web/src/hooks/useRouting.ts @@ -0,0 +1,53 @@ +/** + * React Query hooks for agent routing configuration. + */ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { routingApi } from '@/lib/api/agent'; +import type { AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared'; + +/** Fetch the current routing config */ +export function useRoutingConfig() { + return useQuery({ + queryKey: ['routing-config'], + queryFn: routingApi.getConfig, + staleTime: 60_000, + }); +} + +/** Update routing config */ +export function useUpdateRoutingConfig() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: routingApi.updateConfig, + onSuccess: (data) => { + queryClient.setQueryData(['routing-config'], data); + }, + }); +} + +/** Resolve agent for an existing task */ +export function useResolveAgent(taskId: string | undefined) { + return useQuery({ + queryKey: ['routing-resolve', taskId], + queryFn: () => routingApi.resolveForTask(taskId!), + enabled: !!taskId, + staleTime: 30_000, + }); +} + +/** Resolve agent for ad-hoc metadata (e.g. from create dialog) */ +export function useResolveAgentForMetadata( + metadata: { + type?: string; + priority?: string; + project?: string; + subtaskCount?: number; + } | null +) { + return useQuery({ + queryKey: ['routing-resolve-meta', metadata], + queryFn: () => routingApi.resolveForMetadata(metadata!), + enabled: !!metadata, + staleTime: 15_000, + }); +} diff --git a/web/src/lib/api/agent.ts b/web/src/lib/api/agent.ts index 59d243ea..d56e7fb8 100644 --- a/web/src/lib/api/agent.ts +++ b/web/src/lib/api/agent.ts @@ -1,7 +1,7 @@ /** * Agent, worktree, and preview API endpoints. */ -import type { AgentType } from '@veritas-kanban/shared'; +import type { AgentType, AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared'; import { API_BASE, handleResponse } from './helpers'; export const worktreeApi = { @@ -102,6 +102,52 @@ export const agentApi = { }, }; +export const routingApi = { + /** Get current routing configuration */ + getConfig: async (): Promise => { + const response = await fetch(`${API_BASE}/agents/routing`); + return handleResponse(response); + }, + + /** Update routing configuration */ + updateConfig: async (config: AgentRoutingConfig): Promise => { + const response = await fetch(`${API_BASE}/agents/routing`, { + credentials: 'include', + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }); + return handleResponse(response); + }, + + /** Resolve the best agent for a task */ + resolveForTask: async (taskId: string): Promise => { + const response = await fetch(`${API_BASE}/agents/route`, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ taskId }), + }); + return handleResponse(response); + }, + + /** Resolve the best agent for metadata (ad-hoc, e.g. from create dialog) */ + resolveForMetadata: async (metadata: { + type?: string; + priority?: string; + project?: string; + subtaskCount?: number; + }): Promise => { + const response = await fetch(`${API_BASE}/agents/route`, { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(metadata), + }); + return handleResponse(response); + }, +}; + export const previewApi = { getStatus: async (taskId: string): Promise => { const response = await fetch(`${API_BASE}/preview/${taskId}`);