feat(agents): mcpd repos + Agent/Chat services with tool-use loop (Stage 2)
Layers the persistence-side logic on top of the Stage 1 schema. AgentService
mirrors LlmService's CRUD shape with name-resolved llm/project references and
yaml round-trip support; ChatService is the orchestrator that drives one chat
turn end-to-end: build the merged system block (agent.systemPrompt + project
Prompts ordered by priority desc + per-call systemAppend), persist the user
turn, run the adapter, dispatch any tool_calls through an injected
ChatToolDispatcher, persist tool turns linked back via toolCallId, and loop
until the model returns terminal text.
Per-call params resolve LiteLLM-style: request body → agent.defaultParams →
adapter default. The escape hatch `extra` is forwarded as-is so each adapter
can cherry-pick provider-specific knobs (Anthropic metadata, vLLM
repetition_penalty, etc.) without code changes here.
Persistence is non-transactional across the loop because tool calls can take
minutes; long-held DB transactions would starve other writers. Instead each
in-flight assistant turn is written `pending` and flipped to `complete` only
after its tool results land. On failure or max-iter overrun, every `pending`
row in the thread is flipped to `error` so the trail is auditable.
Tools are namespaced on the wire as `<server>__<tool>`, unmarshalled at
dispatch time; `tools_allowlist` filters before the model sees the list.
Tests:
agent-service.test.ts (7) — CRUD with name-resolved llm/project, conflict
on duplicate, llm switch, project detach, listByProject filtering,
upsertByName branch coverage.
chat-service.test.ts (9) — plain text turn, full text→tool→text loop with
toolCallId linkage, max-iter cap leaves zero pending, adapter-throws
leaves zero pending, body→defaultParams merge, `extra` passthrough,
project-Prompt priority ordering in the system block, tool-without-
project rejection, tools_allowlist filtering.
All 16 green; full mcpd suite still 737/737.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
192
src/mcpd/tests/agent-service.test.ts
Normal file
192
src/mcpd/tests/agent-service.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AgentService } from '../src/services/agent.service.js';
|
||||
import type { IAgentRepository } from '../src/repositories/agent.repository.js';
|
||||
import type { LlmService } from '../src/services/llm.service.js';
|
||||
import type { ProjectService } from '../src/services/project.service.js';
|
||||
import type { Agent } from '@prisma/client';
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: 'agent-1',
|
||||
name: 'reviewer',
|
||||
description: '',
|
||||
systemPrompt: '',
|
||||
llmId: 'llm-1',
|
||||
projectId: null,
|
||||
proxyModelName: null,
|
||||
defaultParams: {} as Agent['defaultParams'],
|
||||
extras: {} as Agent['extras'],
|
||||
ownerId: 'owner-1',
|
||||
version: 1,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function mockRepo(initial: Agent[] = []): IAgentRepository {
|
||||
const rows = new Map<string, Agent>(initial.map((r) => [r.id, r]));
|
||||
return {
|
||||
findAll: vi.fn(async () => [...rows.values()]),
|
||||
findById: vi.fn(async (id: string) => rows.get(id) ?? null),
|
||||
findByName: vi.fn(async (name: string) => {
|
||||
for (const r of rows.values()) if (r.name === name) return r;
|
||||
return null;
|
||||
}),
|
||||
findByProjectId: vi.fn(async (projectId: string) =>
|
||||
[...rows.values()].filter((r) => r.projectId === projectId)),
|
||||
create: vi.fn(async (data) => {
|
||||
const row = makeAgent({
|
||||
id: `agent-${String(rows.size + 1)}`,
|
||||
name: data.name,
|
||||
description: data.description ?? '',
|
||||
systemPrompt: data.systemPrompt ?? '',
|
||||
llmId: data.llmId,
|
||||
projectId: data.projectId ?? null,
|
||||
proxyModelName: data.proxyModelName ?? null,
|
||||
defaultParams: (data.defaultParams ?? {}) as Agent['defaultParams'],
|
||||
extras: (data.extras ?? {}) as Agent['extras'],
|
||||
ownerId: data.ownerId,
|
||||
});
|
||||
rows.set(row.id, row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async (id, data) => {
|
||||
const existing = rows.get(id);
|
||||
if (!existing) throw new Error('not found');
|
||||
const next: Agent = {
|
||||
...existing,
|
||||
...(data.description !== undefined ? { description: data.description } : {}),
|
||||
...(data.systemPrompt !== undefined ? { systemPrompt: data.systemPrompt } : {}),
|
||||
...(data.llmId !== undefined ? { llmId: data.llmId } : {}),
|
||||
...(data.projectId !== undefined ? { projectId: data.projectId } : {}),
|
||||
...(data.proxyModelName !== undefined ? { proxyModelName: data.proxyModelName } : {}),
|
||||
...(data.defaultParams !== undefined ? { defaultParams: data.defaultParams as Agent['defaultParams'] } : {}),
|
||||
...(data.extras !== undefined ? { extras: data.extras as Agent['extras'] } : {}),
|
||||
version: existing.version + 1,
|
||||
};
|
||||
rows.set(id, next);
|
||||
return next;
|
||||
}),
|
||||
delete: vi.fn(async (id: string) => {
|
||||
rows.delete(id);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function mockLlms(): LlmService {
|
||||
return {
|
||||
getById: vi.fn(async (id: string) => ({
|
||||
id, name: id === 'llm-1' ? 'qwen3-thinking' : 'other',
|
||||
type: 'openai', model: 'm', url: '', tier: 'fast',
|
||||
description: '', apiKeyRef: null, extraConfig: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
getByName: vi.fn(async (name: string) => ({
|
||||
id: name === 'qwen3-thinking' ? 'llm-1' : 'llm-other',
|
||||
name, type: 'openai', model: 'm', url: '', tier: 'fast',
|
||||
description: '', apiKeyRef: null, extraConfig: {},
|
||||
version: 1, createdAt: new Date(), updatedAt: new Date(),
|
||||
})),
|
||||
} as unknown as LlmService;
|
||||
}
|
||||
|
||||
function mockProjects(): ProjectService {
|
||||
return {
|
||||
getById: vi.fn(async (id: string) => ({ id, name: id === 'proj-1' ? 'mcpctl-dev' : 'other' })),
|
||||
resolveAndGet: vi.fn(async (idOrName: string) => ({
|
||||
id: idOrName === 'mcpctl-dev' ? 'proj-1' : 'proj-other',
|
||||
name: idOrName,
|
||||
})),
|
||||
} as unknown as ProjectService;
|
||||
}
|
||||
|
||||
describe('AgentService', () => {
|
||||
it('creates an agent resolving llm + project by name', async () => {
|
||||
const repo = mockRepo();
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
const view = await svc.create({
|
||||
name: 'reviewer',
|
||||
description: 'I review security',
|
||||
systemPrompt: 'be terse',
|
||||
llm: { name: 'qwen3-thinking' },
|
||||
project: { name: 'mcpctl-dev' },
|
||||
defaultParams: { temperature: 0.2, max_tokens: 4096 },
|
||||
}, 'owner-1');
|
||||
expect(view.name).toBe('reviewer');
|
||||
expect(view.llm.name).toBe('qwen3-thinking');
|
||||
expect(view.project?.name).toBe('mcpctl-dev');
|
||||
expect(view.defaultParams.temperature).toBe(0.2);
|
||||
expect(repo.create).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('creates an agent without a project (null projectId stays null)', async () => {
|
||||
const repo = mockRepo();
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
const view = await svc.create({
|
||||
name: 'standalone',
|
||||
llm: { name: 'qwen3-thinking' },
|
||||
}, 'owner-1');
|
||||
expect(view.project).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects creating an agent with a duplicate name (Conflict)', async () => {
|
||||
const repo = mockRepo([makeAgent({ id: 'a1', name: 'dup' })]);
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
await expect(svc.create({
|
||||
name: 'dup',
|
||||
llm: { name: 'qwen3-thinking' },
|
||||
}, 'owner-1')).rejects.toThrow(/already exists/);
|
||||
});
|
||||
|
||||
it('updates llm reference by name', async () => {
|
||||
const repo = mockRepo([makeAgent({ id: 'a1', name: 'switcher', llmId: 'llm-1' })]);
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
const updated = await svc.update('a1', { llm: { name: 'other' } });
|
||||
expect(updated.llm.id).toBe('llm-other');
|
||||
});
|
||||
|
||||
it('detaches a project when project is set to null', async () => {
|
||||
const repo = mockRepo([makeAgent({ id: 'a1', name: 'attached', projectId: 'proj-1' })]);
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
const updated = await svc.update('a1', { project: null });
|
||||
expect(updated.project).toBeNull();
|
||||
});
|
||||
|
||||
it('listByProject returns only agents in the project', async () => {
|
||||
const repo = mockRepo([
|
||||
makeAgent({ id: 'a1', name: 'in-proj', projectId: 'proj-1' }),
|
||||
makeAgent({ id: 'a2', name: 'no-proj', projectId: null }),
|
||||
makeAgent({ id: 'a3', name: 'other-proj', projectId: 'proj-other' }),
|
||||
]);
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
const list = await svc.listByProject('mcpctl-dev');
|
||||
expect(list.map((a) => a.name)).toEqual(['in-proj']);
|
||||
});
|
||||
|
||||
it('upsertByName creates if missing, updates if present', async () => {
|
||||
const repo = mockRepo();
|
||||
const svc = new AgentService(repo, mockLlms(), mockProjects());
|
||||
|
||||
const created = await svc.upsertByName({
|
||||
name: 'roundtrip',
|
||||
description: 'first',
|
||||
systemPrompt: '',
|
||||
llm: { name: 'qwen3-thinking' },
|
||||
defaultParams: {},
|
||||
extras: {},
|
||||
}, 'owner-1');
|
||||
expect(created.description).toBe('first');
|
||||
|
||||
const updated = await svc.upsertByName({
|
||||
name: 'roundtrip',
|
||||
description: 'second',
|
||||
systemPrompt: '',
|
||||
llm: { name: 'qwen3-thinking' },
|
||||
defaultParams: {},
|
||||
extras: {},
|
||||
}, 'owner-1');
|
||||
expect(updated.description).toBe('second');
|
||||
expect(updated.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user