fix: bound metadata preview counts without placeholder allocation

This commit is contained in:
bradgroux 2026-09-07 12:45:28 -05:00
parent 19886ac952
commit b733d70ac8
9 changed files with 162 additions and 23 deletions

View file

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

View file

@ -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 = [

View file

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

View file

@ -383,7 +383,7 @@ describe('admin-only governance routes', () => {
type: 'feature',
priority: 'medium',
project: undefined,
subtasks: undefined,
subtaskCount: undefined,
},
{ requiredRuntimeCapabilities: undefined }
);

View file

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

View file

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

View file

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

View file

@ -41,7 +41,9 @@ import { selectProviderRuntimeManifest } from './provider-runtime-capability-ser
const log = createLogger('agent-routing');
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'> & {
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, 'type' | 'priority' | 'project' | 'subtasks'>,
task: RoutableTask,
failedAgent: AgentType,
context: FallbackRoutingContext = {}
): Promise<RoutingResult | null> {
@ -636,10 +638,7 @@ export class AgentRoutingService {
* All specified criteria must match (AND logic).
* Unspecified criteria are ignored (wildcard).
*/
private matchesRule(
task: Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>,
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;
}

View file

@ -123,6 +123,7 @@ class CostPredictionService {
project?: string;
description?: string;
subtasks?: Array<unknown>;
subtaskCount?: number;
}): Promise<CostPrediction> {
// 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;