mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-05 11:17:50 +00:00
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
This commit is contained in:
parent
bb453ab6e9
commit
fa7ac0fe1a
11 changed files with 1804 additions and 262 deletions
322
server/src/__tests__/agent-routing-service.test.ts
Normal file
322
server/src/__tests__/agent-routing-service.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
147
server/src/routes/agent-routing.ts
Normal file
147
server/src/routes/agent-routing.ts
Normal file
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
224
server/src/services/agent-routing-service.ts
Normal file
224
server/src/services/agent-routing-service.ts
Normal file
|
|
@ -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<Task, 'type' | 'priority' | 'project' | 'subtasks'>
|
||||
): Promise<RoutingResult> {
|
||||
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<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
failedAgent: AgentType
|
||||
): Promise<RoutingResult | null> {
|
||||
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<AgentRoutingConfig> {
|
||||
const config = await this.configService.getConfig();
|
||||
return config.agentRouting || DEFAULT_ROUTING_CONFIG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update routing config.
|
||||
*/
|
||||
async updateRoutingConfig(routing: AgentRoutingConfig): Promise<AgentRoutingConfig> {
|
||||
// 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<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
|
||||
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<T>(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;
|
||||
}
|
||||
|
|
@ -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)}`;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<TaskGit>;
|
||||
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[];
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Routing Rules */}
|
||||
<RoutingRulesSection agents={config?.agents || []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(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 (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading routing config...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm font-medium hover:text-foreground transition-colors"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Route className="h-4 w-4" />
|
||||
Agent Routing
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{updateRouting.isPending && <SaveIndicator isPending />}
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onCheckedChange={handleToggleEnabled}
|
||||
aria-label="Enable agent routing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={cn('space-y-4', !config.enabled && 'opacity-50 pointer-events-none')}>
|
||||
{/* Rules list */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Rules (first match wins)
|
||||
</h4>
|
||||
{!showAddRule && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowAddRule(true)}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" /> Add Rule
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAddRule && (
|
||||
<RoutingRuleForm
|
||||
agents={enabledAgents}
|
||||
existingIds={config.rules.map((r) => r.id)}
|
||||
onSubmit={handleAddRule}
|
||||
onCancel={() => setShowAddRule(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{config.rules.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-3 text-center border rounded-md border-dashed">
|
||||
No routing rules — all tasks use the default agent.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{config.rules.map((rule, idx) =>
|
||||
editingRuleId === rule.id ? (
|
||||
<RoutingRuleForm
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
agents={enabledAgents}
|
||||
existingIds={config.rules.filter((r) => r.id !== rule.id).map((r) => r.id)}
|
||||
onSubmit={(updated) => handleEditRule(rule.id, updated)}
|
||||
onCancel={() => setEditingRuleId(null)}
|
||||
/>
|
||||
) : (
|
||||
<RoutingRuleItem
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
agents={agents}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === config.rules.length - 1}
|
||||
onToggle={() => handleToggleRule(rule.id)}
|
||||
onEdit={() => setEditingRuleId(rule.id)}
|
||||
onRemove={() => handleRemoveRule(rule.id)}
|
||||
onMoveUp={() => handleMoveRule(rule.id, 'up')}
|
||||
onMoveDown={() => handleMoveRule(rule.id, 'down')}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default & Fallback settings */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Defaults
|
||||
</h4>
|
||||
<Button variant="ghost" size="sm" className="text-xs h-6" onClick={resetRouting}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label className="text-sm">Default Agent</Label>
|
||||
<p className="text-xs text-muted-foreground">Used when no rules match</p>
|
||||
</div>
|
||||
<Select value={config.defaultAgent} onValueChange={handleDefaultAgentChange}>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledAgents.map((a) => (
|
||||
<SelectItem key={a.type} value={a.type}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div>
|
||||
<Label className="text-sm">Default Model</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model override for the default agent
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
value={config.defaultModel || ''}
|
||||
onChange={(e) => handleDefaultModelChange(e.target.value)}
|
||||
placeholder="e.g., sonnet"
|
||||
className="w-[180px] h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ToggleRow
|
||||
label="Fallback on Failure"
|
||||
description="Auto-retry with fallback agent when primary fails"
|
||||
checked={config.fallbackOnFailure}
|
||||
onCheckedChange={handleFallbackToggle}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Max Retries"
|
||||
description="Maximum retry attempts before giving up (0-3)"
|
||||
value={config.maxRetries}
|
||||
onChange={handleMaxRetriesChange}
|
||||
min={0}
|
||||
max={3}
|
||||
hideSpinners
|
||||
maxLength={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 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 (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 py-2 px-3 rounded-md border text-sm',
|
||||
rule.enabled ? 'bg-card' : 'bg-muted/30 opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Reorder buttons */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isFirst}
|
||||
onClick={onMoveUp}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isLast}
|
||||
onClick={onMoveDown}
|
||||
className="text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rule info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{rule.name}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{matchLabels.map((label, i) => (
|
||||
<Badge key={i} variant="secondary" className="text-xs font-mono">
|
||||
{label}
|
||||
</Badge>
|
||||
))}
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agentName}
|
||||
{rule.model ? ` (${rule.model})` : ''}
|
||||
</Badge>
|
||||
{fallbackName && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">fallback:</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{fallbackName}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onEdit}>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRemove}>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
<Switch checked={rule.enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ 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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 border rounded-lg p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Route className="h-4 w-4" />
|
||||
{isEditing ? `Edit Rule: ${rule.name}` : 'Add Routing Rule'}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Rule Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., High-priority bugs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
ID{' '}
|
||||
{!isEditing && effectiveId && (
|
||||
<span className="text-xs text-muted-foreground ml-1">({effectiveId})</span>
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={isEditing ? rule.id : id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="auto from name"
|
||||
disabled={isEditing}
|
||||
className={cn(isDuplicate && 'border-red-500')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match criteria */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Match Type(s) <span className="text-xs text-muted-foreground">(comma-separated)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={matchType}
|
||||
onChange={(e) => setMatchType(e.target.value)}
|
||||
placeholder="e.g., code, bug"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Match Priority{' '}
|
||||
<span className="text-xs text-muted-foreground">(low, medium, high)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={matchPriority}
|
||||
onChange={(e) => setMatchPriority(e.target.value)}
|
||||
placeholder="e.g., high"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Match Project <span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={matchProject}
|
||||
onChange={(e) => setMatchProject(e.target.value)}
|
||||
placeholder="e.g., rubicon"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Min Subtasks <span className="text-xs text-muted-foreground">(complexity)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={minSubtasks}
|
||||
onChange={(e) => setMinSubtasks(e.target.value)}
|
||||
placeholder="e.g., 5"
|
||||
className="font-mono text-sm"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent selection */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid gap-1.5">
|
||||
<Label>Primary Agent</Label>
|
||||
<Select value={agent} onValueChange={setAgent}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.type} value={a.type}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Model <span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="e.g., opus"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label>
|
||||
Fallback Agent <span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={fallback || '__none__'}
|
||||
onValueChange={(v) => setFallback(v === '__none__' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{agents
|
||||
.filter((a) => a.type !== agent)
|
||||
.map((a) => (
|
||||
<SelectItem key={a.type} value={a.type}>
|
||||
{a.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
<X className="h-3.5 w-3.5 mr-1" /> Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!isValid}>
|
||||
<Check className="h-3.5 w-3.5 mr-1" /> {isEditing ? 'Save Rule' : 'Add Rule'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Agent Form (add/edit mode) ============
|
||||
|
||||
function AgentForm({ agent, existingTypes, onSubmit, onCancel }: AgentFormProps) {
|
||||
const isEditing = !!agent;
|
||||
const [name, setName] = useState(agent?.name || '');
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
|
||||
|
||||
const outputRef = useRef<HTMLDivElement>(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) {
|
|||
<Bot className="h-4 w-4" />
|
||||
AI Agent
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{isAgentRunning && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 p-2 border-b bg-card">
|
||||
{!isAgentRunning ? (
|
||||
<>
|
||||
<Select
|
||||
value={selectedAgent || defaultAgent}
|
||||
onValueChange={(v) => setSelectedAgent(v as AgentType)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue placeholder="Select agent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledAgents.map((agent) => (
|
||||
<SelectItem key={agent.type} value={agent.type}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-3 w-3" />
|
||||
{agent.name}
|
||||
{agent.type === defaultAgent && (
|
||||
<span className="text-xs text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={!canStart || startAgent.isPending}
|
||||
>
|
||||
{startAgent.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop the agent?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will terminate the running agent. The attempt will be marked as failed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Stop Agent
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
ref={outputRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[300px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200"
|
||||
>
|
||||
{outputs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Terminal className="h-6 w-6 mr-2 opacity-50" />
|
||||
{isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'}
|
||||
</div>
|
||||
) : (
|
||||
outputs.map((output, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-all',
|
||||
output.type === 'stderr' && 'text-red-400',
|
||||
output.type === 'stdin' && 'text-blue-400 bg-blue-500/10 px-2 py-1 rounded my-1',
|
||||
output.type === 'system' && 'text-yellow-400 italic'
|
||||
)}
|
||||
>
|
||||
{output.type === 'stdin' && <span className="font-bold">You: </span>}
|
||||
{sanitizeText(output.content)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{isAgentRunning && (
|
||||
<form onSubmit={handleSendMessage} className="flex gap-2 p-2 border-t bg-card">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Send a message to the agent..."
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={!message.trim() || sendMessage.isPending}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Attempt button for completed/failed tasks */}
|
||||
{task.attempt && ['complete', 'failed'].includes(task.attempt.status) && !isAgentRunning && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={handleStart}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
New Attempt
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Attempt History */}
|
||||
{attempts && attempts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Attempt History ({attempts.length})
|
||||
</Label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{attempts.map((attemptId) => {
|
||||
const isCurrentAttempt = task.attempt?.id === attemptId;
|
||||
const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete';
|
||||
const isViewing = viewingAttemptId === attemptId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attemptId}
|
||||
className={cn(
|
||||
"flex items-center gap-2 p-2 rounded-md text-xs cursor-pointer transition-colors",
|
||||
isViewing ? "bg-primary/10 border border-primary/30" : "bg-muted/50 hover:bg-muted"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isCurrentAttempt && isAgentRunning) {
|
||||
setViewingAttemptId(null); // Show live output
|
||||
} else {
|
||||
setViewingAttemptId(isViewing ? null : attemptId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{attemptStatusIcons[attemptStatus as AttemptStatus] || attemptStatusIcons.complete}
|
||||
<span className="font-mono flex-1 truncate">{attemptId}</span>
|
||||
{isCurrentAttempt && (
|
||||
<Badge variant="secondary" className="text-xs">Current</Badge>
|
||||
)}
|
||||
{isViewing && !isCurrentAttempt && (
|
||||
<Badge variant="outline" className="text-xs">Viewing</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical attempt log viewer */}
|
||||
{viewingAttemptId && viewingAttemptId !== task.attempt?.id && (
|
||||
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-2 border-b bg-card">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Viewing: {viewingAttemptId}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewingAttemptId(null)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[200px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200">
|
||||
{isLoadingLog ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading log...
|
||||
</div>
|
||||
) : attemptLog ? (
|
||||
<pre className="whitespace-pre-wrap">{attemptLog}</pre>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<Wifi className="h-3 w-3 text-green-500" />
|
||||
) : (
|
||||
<div className="text-muted-foreground">No log available</div>
|
||||
<WifiOff className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{isAgentRunning && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-500">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current attempt info */}
|
||||
{task.attempt && !viewingAttemptId && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 p-2 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{attemptStatusIcons[task.attempt.status]}
|
||||
<span className="font-medium">Current: {task.attempt.id}</span>
|
||||
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2 p-2 border-b bg-card">
|
||||
{!isAgentRunning ? (
|
||||
<>
|
||||
{routingResult && !selectedAgent && (
|
||||
<span
|
||||
className="text-xs text-muted-foreground truncate max-w-[200px]"
|
||||
title={routingResult.reason}
|
||||
>
|
||||
Rec:{' '}
|
||||
{enabledAgents.find((a) => a.type === routingResult.agent)?.name ||
|
||||
routingResult.agent}
|
||||
{routingResult.model ? ` (${routingResult.model})` : ''}
|
||||
</span>
|
||||
)}
|
||||
<Select
|
||||
value={selectedAgent || routingResult?.agent || defaultAgent}
|
||||
onValueChange={(v) => setSelectedAgent(v as AgentType)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8">
|
||||
<SelectValue placeholder="Select agent..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledAgents.map((agent) => (
|
||||
<SelectItem key={agent.type} value={agent.type}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Bot className="h-3 w-3" />
|
||||
{agent.name}
|
||||
{agent.type === defaultAgent && (
|
||||
<span className="text-xs text-muted-foreground">(default)</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={!canStart || startAgent.isPending}
|
||||
>
|
||||
{startAgent.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="destructive">
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Stop the agent?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will terminate the running agent. The attempt will be marked as failed.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleStop}
|
||||
className="bg-destructive text-destructive-foreground"
|
||||
>
|
||||
Stop Agent
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
<div>Agent: {task.attempt.agent}</div>
|
||||
{task.attempt.started && (
|
||||
<div>Started: {new Date(task.attempt.started).toLocaleString()}</div>
|
||||
)}
|
||||
{task.attempt.ended && (
|
||||
<div>Ended: {new Date(task.attempt.ended).toLocaleString()}</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
ref={outputRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[300px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200"
|
||||
>
|
||||
{outputs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
<Terminal className="h-6 w-6 mr-2 opacity-50" />
|
||||
{isAgentRunning ? 'Waiting for output...' : 'Agent output will appear here'}
|
||||
</div>
|
||||
) : (
|
||||
outputs.map((output, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-all',
|
||||
output.type === 'stderr' && 'text-red-400',
|
||||
output.type === 'stdin' &&
|
||||
'text-blue-400 bg-blue-500/10 px-2 py-1 rounded my-1',
|
||||
output.type === 'system' && 'text-yellow-400 italic'
|
||||
)}
|
||||
>
|
||||
{output.type === 'stdin' && <span className="font-bold">You: </span>}
|
||||
{sanitizeText(output.content)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{isAgentRunning && (
|
||||
<form onSubmit={handleSendMessage} className="flex gap-2 p-2 border-t bg-card">
|
||||
<Input
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Send a message to the agent..."
|
||||
className="flex-1 h-8 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" disabled={!message.trim() || sendMessage.isPending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Attempt button for completed/failed tasks */}
|
||||
{task.attempt &&
|
||||
['complete', 'failed'].includes(task.attempt.status) &&
|
||||
!isAgentRunning && (
|
||||
<Button variant="outline" size="sm" className="w-full" onClick={handleStart}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
New Attempt
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Attempt History */}
|
||||
{attempts && attempts.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Attempt History ({attempts.length})
|
||||
</Label>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{attempts.map((attemptId) => {
|
||||
const isCurrentAttempt = task.attempt?.id === attemptId;
|
||||
const attemptStatus = isCurrentAttempt ? task.attempt?.status : 'complete';
|
||||
const isViewing = viewingAttemptId === attemptId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attemptId}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-md text-xs cursor-pointer transition-colors',
|
||||
isViewing
|
||||
? 'bg-primary/10 border border-primary/30'
|
||||
: 'bg-muted/50 hover:bg-muted'
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isCurrentAttempt && isAgentRunning) {
|
||||
setViewingAttemptId(null); // Show live output
|
||||
} else {
|
||||
setViewingAttemptId(isViewing ? null : attemptId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{attemptStatusIcons[attemptStatus as AttemptStatus] ||
|
||||
attemptStatusIcons.complete}
|
||||
<span className="font-mono flex-1 truncate">{attemptId}</span>
|
||||
{isCurrentAttempt && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Current
|
||||
</Badge>
|
||||
)}
|
||||
{isViewing && !isCurrentAttempt && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Viewing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historical attempt log viewer */}
|
||||
{viewingAttemptId && viewingAttemptId !== task.attempt?.id && (
|
||||
<div className="rounded-md border bg-muted/30 overflow-hidden">
|
||||
<div className="flex items-center justify-between p-2 border-b bg-card">
|
||||
<span className="text-xs text-muted-foreground">Viewing: {viewingAttemptId}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setViewingAttemptId(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-[200px] overflow-y-auto p-3 font-mono text-xs bg-zinc-950 text-zinc-200">
|
||||
{isLoadingLog ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading log...
|
||||
</div>
|
||||
) : attemptLog ? (
|
||||
<pre className="whitespace-pre-wrap">{attemptLog}</pre>
|
||||
) : (
|
||||
<div className="text-muted-foreground">No log available</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current attempt info */}
|
||||
{task.attempt && !viewingAttemptId && (
|
||||
<div className="text-xs text-muted-foreground space-y-1 p-2 rounded-md bg-muted/30">
|
||||
<div className="flex items-center gap-2">
|
||||
{attemptStatusIcons[task.attempt.status]}
|
||||
<span className="font-medium">Current: {task.attempt.id}</span>
|
||||
</div>
|
||||
<div>Agent: {task.attempt.agent}</div>
|
||||
{task.attempt.started && (
|
||||
<div>Started: {new Date(task.attempt.started).toLocaleString()}</div>
|
||||
)}
|
||||
{task.attempt.ended && (
|
||||
<div>Ended: {new Date(task.attempt.ended).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FeatureErrorBoundary>
|
||||
);
|
||||
|
|
|
|||
53
web/src/hooks/useRouting.ts
Normal file
53
web/src/hooks/useRouting.ts
Normal file
|
|
@ -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<AgentRoutingConfig>({
|
||||
queryKey: ['routing-config'],
|
||||
queryFn: routingApi.getConfig,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Update routing config */
|
||||
export function useUpdateRoutingConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<AgentRoutingConfig, Error, AgentRoutingConfig>({
|
||||
mutationFn: routingApi.updateConfig,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['routing-config'], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve agent for an existing task */
|
||||
export function useResolveAgent(taskId: string | undefined) {
|
||||
return useQuery<RoutingResult>({
|
||||
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<RoutingResult>({
|
||||
queryKey: ['routing-resolve-meta', metadata],
|
||||
queryFn: () => routingApi.resolveForMetadata(metadata!),
|
||||
enabled: !!metadata,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<AgentRoutingConfig> => {
|
||||
const response = await fetch(`${API_BASE}/agents/routing`);
|
||||
return handleResponse<AgentRoutingConfig>(response);
|
||||
},
|
||||
|
||||
/** Update routing configuration */
|
||||
updateConfig: async (config: AgentRoutingConfig): Promise<AgentRoutingConfig> => {
|
||||
const response = await fetch(`${API_BASE}/agents/routing`, {
|
||||
credentials: 'include',
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return handleResponse<AgentRoutingConfig>(response);
|
||||
},
|
||||
|
||||
/** Resolve the best agent for a task */
|
||||
resolveForTask: async (taskId: string): Promise<RoutingResult> => {
|
||||
const response = await fetch(`${API_BASE}/agents/route`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ taskId }),
|
||||
});
|
||||
return handleResponse<RoutingResult>(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<RoutingResult> => {
|
||||
const response = await fetch(`${API_BASE}/agents/route`, {
|
||||
credentials: 'include',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(metadata),
|
||||
});
|
||||
return handleResponse<RoutingResult>(response);
|
||||
},
|
||||
};
|
||||
|
||||
export const previewApi = {
|
||||
getStatus: async (taskId: string): Promise<PreviewServer | { status: 'stopped' }> => {
|
||||
const response = await fetch(`${API_BASE}/preview/${taskId}`);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue