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:
Brad Groux 2026-01-31 05:18:44 -06:00
parent bb453ab6e9
commit fa7ac0fe1a
11 changed files with 1804 additions and 262 deletions

View 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');
});
});
});

View 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 };

View file

@ -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);

View 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;
}

View file

@ -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)}`;

View file

@ -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
},
};

View file

@ -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[];

View file

@ -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 || '');

View file

@ -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>
);

View 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,
});
}

View file

@ -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}`);