diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index d33fc191..bf4d0a7b 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -2405,7 +2405,7 @@ Mounted at `/api/agents`. POST /api/agents/route ``` -Accepts either a task ID or ad-hoc metadata: +Accepts either a task ID or ad-hoc metadata: Metadata `subtaskCount` must be an integer from 0 through 500; it is evaluated as a count without creating task records. **By task ID**: @@ -3900,6 +3900,8 @@ Mounted at `/api/cost-prediction`. POST /api/cost-prediction/predict ``` +Metadata `subtaskCount` accepts integers from 0 through 500. Existing tasks are evaluated using their stored subtask count. + **By task ID**: ```json diff --git a/server/src/__tests__/agent-routing-service.test.ts b/server/src/__tests__/agent-routing-service.test.ts index 956eb6e7..d4add7c4 100644 --- a/server/src/__tests__/agent-routing-service.test.ts +++ b/server/src/__tests__/agent-routing-service.test.ts @@ -462,7 +462,7 @@ describe('AgentRoutingService', () => { id: 'high-code', name: 'High-priority code owner', enabled: true, - match: { type: 'code', priority: 'high' }, + match: { type: 'code', priority: 'high', minSubtasks: 5 }, memberId: 'ops-lead', }, ], @@ -472,6 +472,7 @@ describe('AgentRoutingService', () => { const result = await service.resolveAgentWithTrace({ type: 'code', priority: 'high', + subtaskCount: 5, }); expect(result.result.agent).toBe('amp'); @@ -695,6 +696,36 @@ describe('AgentRoutingService', () => { expect(result.rule).toBe('complex'); }); + it('routes scalar counts like real subtask collections', async () => { + const config = structuredClone(BASE_CONFIG); + requireRouting(config).rules = [ + { + id: 'complex', + name: 'Complex tasks', + match: { minSubtasks: 5 }, + agent: 'amp', + enabled: true, + }, + ]; + mockGetConfig.mockResolvedValue(config); + expect( + (await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 5 })).rule + ).toBe('complex'); + expect( + (await service.resolveAgent({ type: 'feature', priority: 'medium', subtaskCount: 4 })).rule + ).toBeUndefined(); + expect( + ( + await service.resolveAgent({ + type: 'feature', + priority: 'medium', + subtasks: [], + subtaskCount: 5, + }) + ).rule + ).toBeUndefined(); + }); + it('does NOT match when subtasks below threshold', async () => { const config = structuredClone(BASE_CONFIG); requireRouting(config).rules = [ diff --git a/server/src/__tests__/cost-prediction-count.test.ts b/server/src/__tests__/cost-prediction-count.test.ts new file mode 100644 index 00000000..18579324 --- /dev/null +++ b/server/src/__tests__/cost-prediction-count.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +vi.mock('../services/telemetry-service.js', () => ({ + getTelemetryService: () => ({ getEvents: async () => [] }), +})); +vi.mock('../services/task-service.js', () => ({ getTaskService: () => ({}) })); +import { getCostPredictionService } from '../services/cost-prediction-service.js'; + +describe('cost prediction subtask counts', () => { + it.each([0, 1, 2, 3, 5, 6, 500])('preserves prediction factors for count %i', async (count) => { + const service = getCostPredictionService(); + const scalar = await service.predict({ subtaskCount: count }); + const stored = await service.predict({ subtasks: Array.from({ length: count }) }); + expect(scalar.factors).toEqual(stored.factors); + expect(scalar.estimatedCost).toBe(stored.estimatedCost); + }); + + it('uses actual task subtasks when both representations are present', async () => { + const service = getCostPredictionService(); + const actual = await service.predict({ subtasks: [], subtaskCount: 500 }); + const empty = await service.predict({ subtasks: [] }); + expect(actual.factors).toEqual(empty.factors); + }); +}); diff --git a/server/src/__tests__/routes/admin-governance-auth.test.ts b/server/src/__tests__/routes/admin-governance-auth.test.ts index e2188920..9eb5b463 100644 --- a/server/src/__tests__/routes/admin-governance-auth.test.ts +++ b/server/src/__tests__/routes/admin-governance-auth.test.ts @@ -383,7 +383,7 @@ describe('admin-only governance routes', () => { type: 'feature', priority: 'medium', project: undefined, - subtasks: undefined, + subtaskCount: undefined, }, { requiredRuntimeCapabilities: undefined } ); diff --git a/server/src/__tests__/routes/metadata-preview-bounds.test.ts b/server/src/__tests__/routes/metadata-preview-bounds.test.ts new file mode 100644 index 00000000..68e87ac5 --- /dev/null +++ b/server/src/__tests__/routes/metadata-preview-bounds.test.ts @@ -0,0 +1,90 @@ +import express from 'express'; +import request from 'supertest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthenticatedRequest } from '../../middleware/auth.js'; +import { authorizeWrite } from '../../middleware/auth.js'; +import { agentRoutingAccess, costPredictionAccess } from '../../routes/v1/permissions.js'; +import { errorHandler } from '../../middleware/error-handler.js'; + +const mocks = vi.hoisted(() => ({ + route: vi.fn(), + predict: vi.fn(), + record: vi.fn(), + getTask: vi.fn(), +})); +vi.mock('../../services/agent-routing-service.js', () => ({ + getAgentRoutingService: () => ({ resolveAgentWithTrace: mocks.route }), +})); +vi.mock('../../services/cost-prediction-service.js', () => ({ + getCostPredictionService: () => ({ predict: mocks.predict }), +})); +vi.mock('../../services/governance-trace-service.js', () => ({ + getGovernanceTraceService: () => ({ record: mocks.record }), +})); +vi.mock('../../services/task-service.js', () => ({ + getTaskService: () => ({ getTask: mocks.getTask }), +})); +import { agentRoutingRoutes } from '../../routes/agent-routing.js'; +import { costPredictionRoutes } from '../../routes/cost-prediction.js'; + +function createApp() { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as AuthenticatedRequest).auth = { + role: 'agent', + isLocalhost: false, + permissions: ['agent:read', 'task:write'], + }; + next(); + }); + app.use(authorizeWrite); + app.use(['/api/agents', '/api/v1/agents'], agentRoutingAccess, agentRoutingRoutes); + app.use( + ['/api/cost-prediction', '/api/v1/cost-prediction'], + costPredictionAccess, + costPredictionRoutes + ); + app.use(errorHandler); + return app; +} + +describe('bounded metadata previews', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.route.mockResolvedValue({ result: { agent: 'fixture' }, trace: {} }); + mocks.predict.mockResolvedValue({ estimatedCost: 1 }); + mocks.record.mockResolvedValue({ id: 'trace_fixture' }); + }); + + for (const prefix of ['/api', '/api/v1']) { + for (const endpoint of ['/agents/route', '/cost-prediction/predict']) { + it(`rejects invalid counts before evaluation at ${prefix}${endpoint}`, async () => { + const app = createApp(); + // 501 proves the bound without risking a large allocation on a regressed build. + for (const subtaskCount of [-1, 0.5, 501, '5', null]) { + const response = await request(app) + .post(prefix + endpoint) + .send({ subtaskCount }); + expect(response.status).toBe(400); + expect(mocks.route).not.toHaveBeenCalled(); + expect(mocks.predict).not.toHaveBeenCalled(); + } + }); + + it(`passes valid counts without materializing subtasks at ${prefix}${endpoint}`, async () => { + const app = createApp(); + for (const subtaskCount of [0, 1, 500]) { + await request(app) + .post(prefix + endpoint) + .send({ subtaskCount }) + .expect(200); + const call = + endpoint === '/agents/route' ? mocks.route.mock.lastCall : mocks.predict.mock.lastCall; + expect(call?.[0].subtaskCount).toBe(subtaskCount); + expect(call?.[0]).not.toHaveProperty('subtasks'); + } + }); + } + } +}); diff --git a/server/src/routes/agent-routing.ts b/server/src/routes/agent-routing.ts index 87db9e9e..71ee5350 100644 --- a/server/src/routes/agent-routing.ts +++ b/server/src/routes/agent-routing.ts @@ -41,7 +41,7 @@ const routeByMetadataSchema = z type: z.string().optional(), priority: z.enum(['low', 'medium', 'high']).optional(), project: z.string().optional(), - subtaskCount: z.number().int().nonnegative().optional(), + subtaskCount: z.number().int().min(0).max(500).optional(), requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema, }) .strict(); @@ -129,14 +129,7 @@ router.post( 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, + subtaskCount, }, { requiredRuntimeCapabilities } ); diff --git a/server/src/routes/cost-prediction.ts b/server/src/routes/cost-prediction.ts index b22dc480..8b494ada 100644 --- a/server/src/routes/cost-prediction.ts +++ b/server/src/routes/cost-prediction.ts @@ -26,7 +26,7 @@ const predictByMetadataSchema = z.object({ priority: z.enum(['low', 'medium', 'high']).optional(), project: z.string().optional(), description: z.string().optional(), - subtaskCount: z.number().int().nonnegative().optional(), + subtaskCount: z.number().int().min(0).max(500).optional(), }); // ─── Routes ────────────────────────────────────────────────────── @@ -77,7 +77,7 @@ router.post( priority, project, description, - subtasks: subtaskCount ? Array.from({ length: subtaskCount }) : undefined, + subtaskCount, }); return res.json(prediction); } diff --git a/server/src/services/agent-routing-service.ts b/server/src/services/agent-routing-service.ts index b3ea64e3..0b112613 100644 --- a/server/src/services/agent-routing-service.ts +++ b/server/src/services/agent-routing-service.ts @@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser const log = createLogger('agent-routing'); -type RoutableTask = Pick; +type RoutableTask = Pick & { + subtaskCount?: number; +}; interface RoutingTraceContext { taskId?: string; @@ -114,7 +116,7 @@ export class AgentRoutingService { type: task.type, priority: task.priority, project: task.project, - subtaskCount: task.subtasks?.length, + subtaskCount: task.subtasks?.length ?? task.subtaskCount, }, config.teamRoster ); @@ -433,7 +435,7 @@ export class AgentRoutingService { * Used when an agent fails and `fallbackOnFailure` is enabled. */ async getFallback( - task: Pick, + task: RoutableTask, failedAgent: AgentType, context: FallbackRoutingContext = {} ): Promise { @@ -636,10 +638,7 @@ export class AgentRoutingService { * All specified criteria must match (AND logic). * Unspecified criteria are ignored (wildcard). */ - private matchesRule( - task: Pick, - match: RoutingMatchCriteria - ): boolean { + private matchesRule(task: RoutableTask, match: RoutingMatchCriteria): boolean { // Type check if (match.type !== undefined) { if (!this.matchesValue(task.type, match.type)) return false; @@ -658,7 +657,7 @@ export class AgentRoutingService { // Complexity (subtask count) if (match.minSubtasks !== undefined) { - const subtaskCount = task.subtasks?.length ?? 0; + const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0; if (subtaskCount < match.minSubtasks) return false; } diff --git a/server/src/services/cost-prediction-service.ts b/server/src/services/cost-prediction-service.ts index dfda471a..11bf9545 100644 --- a/server/src/services/cost-prediction-service.ts +++ b/server/src/services/cost-prediction-service.ts @@ -123,6 +123,7 @@ class CostPredictionService { project?: string; description?: string; subtasks?: Array; + subtaskCount?: number; }): Promise { // 1. Get historical base cost from telemetry const historicalBase = await this.getHistoricalBaseCost(task.type, task.project); @@ -138,7 +139,7 @@ class CostPredictionService { // 4. Estimate complexity from description length + subtask count const descLength = (task.description || '').length; - const subtaskCount = task.subtasks?.length || 0; + const subtaskCount = task.subtasks?.length ?? task.subtaskCount ?? 0; let complexityMultiplier: number; if (descLength < COMPLEXITY_THRESHOLDS.simple && subtaskCount === 0) { complexityMultiplier = COMPLEXITY_MULTIPLIERS.simple;