diff --git a/.env.example b/.env.example index f2d1a0b..8fe1559 100644 --- a/.env.example +++ b/.env.example @@ -45,6 +45,12 @@ AGENT_FORCE=true # Working directory passed to agent as cwd AGENT_REPO_PATH=. +# Optional: enable the in-process Workspace — a git-SHA-addressed discovery cache +# (file tree + conventions) whose context pack is prepended to each task prompt so +# the agent need not cold-scan the repo every run. Default false (no context pack, +# behavior unchanged). Operates on AGENT_REPO_PATH. +WORKSPACE_ENABLED=false + # Optional repo-path confinement: comma-separated allow-list of root directories. # When set, a caller-supplied repoPath (e.g. the MCP coding_agent_run `repoPath` arg) # must resolve to one of these roots or a subdirectory; otherwise the task is rejected. diff --git a/src/agent-task-executor.ts b/src/agent-task-executor.ts index b29e5f0..9c3da22 100644 --- a/src/agent-task-executor.ts +++ b/src/agent-task-executor.ts @@ -6,6 +6,8 @@ import type { ProcessAdapter } from './adapters/base.js'; import type { Runner } from './runner.js'; import type { Router } from './routing/router.js'; import { FixedRouter } from './routing/router.js'; +import type { Workspace } from './context/workspace.js'; +import { augmentTaskPrompt } from './context/augment.js'; import { ProcessRunner } from './process-runner.js'; import { mapAgentEventToA2A } from './a2a-mapper.js'; import { v4 as uuidv4 } from 'uuid'; @@ -13,15 +15,19 @@ import { v4 as uuidv4 } from 'uuid'; export class AgentTaskExecutor implements AgentExecutor { private readonly _config: Config; private readonly _router: Router; + private readonly _workspace: Workspace | undefined; private readonly _activeRunners = new Map(); /** * @param router - Per-request adapter selector. Defaults to a {@link FixedRouter} around * `adapter` (routing disabled) so existing callers are unchanged. + * @param workspace - Optional context source. When absent, no context pack is injected and + * the task prompt is used verbatim (byte-identical to before). */ - constructor(config: Config, adapter: ProcessAdapter, router?: Router) { + constructor(config: Config, adapter: ProcessAdapter, router?: Router, workspace?: Workspace) { this._config = config; this._router = router ?? new FixedRouter(adapter); + this._workspace = workspace; } execute = async (requestContext: RequestContext, eventBus: ExecutionEventBus): Promise => { @@ -51,7 +57,12 @@ export class AgentTaskExecutor implements AgentExecutor { const route = this._router.select(prompt, readProfile(requestContext.userMessage.metadata)); const runConfig = route.model !== undefined ? { ...this._config, agentModel: route.model } : this._config; - runner = new ProcessRunner({ task: prompt, adapter: route.adapter, config: runConfig }); + let task = prompt; + if (this._workspace) { + const pack = await this._workspace.getContextPack(prompt); + task = augmentTaskPrompt(prompt, pack); + } + runner = new ProcessRunner({ task, adapter: route.adapter, config: runConfig }); this._activeRunners.set(taskId, runner); return new Promise((resolve) => { diff --git a/src/config.ts b/src/config.ts index 664199f..c7fbcd0 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,6 +47,7 @@ const ConfigSchema = z.object({ agentForce: boolEnv(true), agentRepoPath: z.string().default('.'), allowedRepoRoots: z.array(z.string()).optional(), + workspaceEnabled: boolEnv(false), mcpTransport: z.preprocess( (val) => (val === 'http' ? 'http' : val === 'stdio' ? 'stdio' : undefined), z.enum(['stdio', 'http']).default('stdio'), @@ -112,6 +113,7 @@ function envVals(): Record { agentIdleExitMs: process.env['AGENT_IDLE_EXIT_MS'], agentForce: process.env['AGENT_FORCE'], agentRepoPath: process.env['AGENT_REPO_PATH'], + workspaceEnabled: process.env['WORKSPACE_ENABLED'], allowedRepoRoots: allowedRepoRootsRaw ? allowedRepoRootsRaw.split(',').map((s) => s.trim()).filter(Boolean) : undefined, diff --git a/src/context/augment.ts b/src/context/augment.ts new file mode 100644 index 0000000..9d824cb --- /dev/null +++ b/src/context/augment.ts @@ -0,0 +1,27 @@ +import type { ContextPack } from './workspace.js'; + +/** Maximum number of file paths to inline in the context header. */ +const MAX_FILES = 50; + +/** + * Prepends a compact workspace-context block to a task prompt, drawn from the discovery cache. + * + * Returns the task **unchanged** when the pack carries nothing (e.g. from `NullWorkspace` or a + * disabled workspace) — this is what keeps the no-workspace path byte-identical. + */ +export function augmentTaskPrompt(task: string, pack: ContextPack): string { + const sections: string[] = []; + if (pack.conventions.agentsMd) sections.push(`AGENTS.md:\n${pack.conventions.agentsMd}`); + if (pack.conventions.claudeMd) sections.push(`CLAUDE.md:\n${pack.conventions.claudeMd}`); + if (pack.conventions.testCommand) sections.push(`Test command: ${pack.conventions.testCommand}`); + if (pack.symbols.length > 0) { + sections.push(`Symbols: ${pack.symbols.map((s) => s.name).join(', ')}`); + } + if (pack.files.length > 0) { + const shown = pack.files.slice(0, MAX_FILES).join(', '); + const more = pack.files.length > MAX_FILES ? `, …(+${pack.files.length - MAX_FILES})` : ''; + sections.push(`Files (${pack.files.length}): ${shown}${more}`); + } + if (sections.length === 0) return task; + return `\n${sections.join('\n\n')}\n\n\n${task}`; +} diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 0000000..eb2842e --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1,14 @@ +import type { Config } from '../types.js'; +import type { Workspace } from './workspace.js'; +import { InProcessWorkspace } from './in-process-workspace.js'; + +/** + * Builds the {@link Workspace} for the running server. + * + * Returns an {@link InProcessWorkspace} over `agentRepoPath` when `workspaceEnabled` is set, + * otherwise `undefined` — and consumers treat "no workspace" as "no context pack", keeping the + * default path byte-identical. + */ +export function createWorkspace(config: Config): Workspace | undefined { + return config.workspaceEnabled ? new InProcessWorkspace(config.agentRepoPath) : undefined; +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 01eee50..72effd3 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -3,6 +3,7 @@ import type { ProcessAdapter } from '../adapters/base.js'; import type { Config } from '../types.js'; import { McpTaskManager } from './task-manager.js'; import { createRouter } from '../routing/router.js'; +import { createWorkspace } from '../context/index.js'; import { registerTools } from './tools.js'; /** @@ -21,6 +22,6 @@ export function createMcpServer( version: process.env['npm_package_version'] ?? '0.1.0', }); const taskManager = new McpTaskManager(adapter, config, createRouter(config, adapter)); - registerTools(server, adapter, taskManager); + registerTools(server, adapter, taskManager, createWorkspace(config)); return { server, taskManager }; } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 026fef4..5928485 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -2,11 +2,14 @@ import { z } from 'zod'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { McpTaskManager } from './task-manager.js'; import type { CodingAgentAdapter } from '../adapters/base.js'; +import type { Workspace } from '../context/workspace.js'; +import { augmentTaskPrompt } from '../context/augment.js'; export function registerTools( server: McpServer, adapter: CodingAgentAdapter, taskManager: McpTaskManager, + workspace?: Workspace, ): void { server.registerTool( 'coding_agent_run', @@ -22,10 +25,13 @@ export function registerTools( profile: z.string().optional().describe('Routing profile override: COMPLEX | MID | ROUTINE (optional; classifier decides when omitted)'), }, }, - (args) => { + async (args) => { let jobId: string; try { - jobId = taskManager.startJob(args.task, { + const task = workspace + ? augmentTaskPrompt(args.task, await workspace.getContextPack(args.task)) + : args.task; + jobId = taskManager.startJob(task, { model: args.model, repoPath: args.repoPath, force: args.force, diff --git a/src/server.ts b/src/server.ts index b24b015..6cd6c38 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,6 +11,7 @@ import type { TokenVerifier } from './auth/verifier.js'; import { createTokenVerifier } from './auth/verifier.js'; import { AgentTaskExecutor } from './agent-task-executor.js'; import { createRouter } from './routing/router.js'; +import { createWorkspace } from './context/index.js'; export interface AppOptions { executor?: AgentExecutor; @@ -23,7 +24,8 @@ export function createApp( options?: AppOptions, ): Express { const agentCard = buildAgentCard(config, adapter); - const activeExecutor = options?.executor ?? new AgentTaskExecutor(config, adapter, createRouter(config, adapter)); + const activeExecutor = options?.executor + ?? new AgentTaskExecutor(config, adapter, createRouter(config, adapter), createWorkspace(config)); const taskStore = new InMemoryTaskStore(); const handler = new DefaultRequestHandler(agentCard, taskStore, activeExecutor); diff --git a/tests/unit/agent-task-executor.test.ts b/tests/unit/agent-task-executor.test.ts index 023fdec..b69eedc 100644 --- a/tests/unit/agent-task-executor.test.ts +++ b/tests/unit/agent-task-executor.test.ts @@ -398,3 +398,25 @@ describe('AgentTaskExecutor settled finalizer', () => { expect(getMockRunner().resume).not.toHaveBeenCalled(); }); }); + +describe('AgentTaskExecutor workspace context', () => { + it('injects the context pack into the task prompt when a workspace is present', async () => { + const workspace = { + repoId: '/repo', + getContextPack: vi.fn(() => Promise.resolve({ + files: ['a.ts'], conventions: { testCommand: 'vitest run' }, symbols: [], truncated: false, + })), + refresh: vi.fn(() => Promise.resolve()), + }; + const executor = new AgentTaskExecutor(baseConfig, mockAdapter, undefined, workspace); + void executor.execute(makeContext({ taskId: 'task-ws' }), makeBus() as never); + // allow the awaited getContextPack microtask + runner construction to complete + await new Promise((r) => setTimeout(r, 0)); + + expect(workspace.getContextPack).toHaveBeenCalledWith('do stuff'); + const call = vi.mocked(ProcessRunner).mock.calls.at(-1)?.[0] as { task: string }; + expect(call.task).toContain(''); + expect(call.task).toContain('Test command: vitest run'); + expect(call.task.endsWith('do stuff')).toBe(true); + }); +}); diff --git a/tests/unit/context/augment.test.ts b/tests/unit/context/augment.test.ts new file mode 100644 index 0000000..4a7feed --- /dev/null +++ b/tests/unit/context/augment.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { augmentTaskPrompt } from '../../../src/context/augment.js'; +import { emptyContextPack, type ContextPack } from '../../../src/context/workspace.js'; + +function pack(overrides: Partial): ContextPack { + return { ...emptyContextPack(), ...overrides }; +} + +describe('augmentTaskPrompt', () => { + it('returns the task unchanged for an empty pack', () => { + expect(augmentTaskPrompt('do stuff', emptyContextPack())).toBe('do stuff'); + }); + + it('includes AGENTS.md, CLAUDE.md, and the test command', () => { + const out = augmentTaskPrompt('t', pack({ + conventions: { agentsMd: 'A', claudeMd: 'C', testCommand: 'vitest run' }, + })); + expect(out).toContain(''); + expect(out).toContain('AGENTS.md:\nA'); + expect(out).toContain('CLAUDE.md:\nC'); + expect(out).toContain('Test command: vitest run'); + expect(out.endsWith('\n\nt')).toBe(true); // task preserved at the end + }); + + it('lists symbols when present', () => { + const out = augmentTaskPrompt('t', pack({ symbols: [{ name: 'foo', kind: 'function', file: 'a.ts' }] })); + expect(out).toContain('Symbols: foo'); + }); + + it('lists files with a count', () => { + const out = augmentTaskPrompt('t', pack({ files: ['a.ts', 'b.ts'] })); + expect(out).toContain('Files (2): a.ts, b.ts'); + }); + + it('truncates long file lists', () => { + const files = Array.from({ length: 60 }, (_, i) => `f${i}.ts`); + const out = augmentTaskPrompt('t', pack({ files })); + expect(out).toContain('Files (60):'); + expect(out).toContain('…(+10)'); + }); +}); diff --git a/tests/unit/context/create-workspace.test.ts b/tests/unit/context/create-workspace.test.ts new file mode 100644 index 0000000..9ce913d --- /dev/null +++ b/tests/unit/context/create-workspace.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { createWorkspace } from '../../../src/context/index.js'; +import { InProcessWorkspace } from '../../../src/context/in-process-workspace.js'; +import type { Config } from '../../../src/types.js'; + +const base = { agentRepoPath: '/repo', workspaceEnabled: false } as unknown as Config; + +describe('createWorkspace', () => { + it('returns undefined when workspaceEnabled is false', () => { + expect(createWorkspace(base)).toBeUndefined(); + }); + + it('returns an InProcessWorkspace over agentRepoPath when enabled', () => { + const ws = createWorkspace({ ...base, workspaceEnabled: true }); + expect(ws).toBeInstanceOf(InProcessWorkspace); + expect(ws?.repoId).toBe('/repo'); + }); +}); diff --git a/tests/unit/mcp/tools.test.ts b/tests/unit/mcp/tools.test.ts index 267715f..4d44c83 100644 --- a/tests/unit/mcp/tools.test.ts +++ b/tests/unit/mcp/tools.test.ts @@ -163,3 +163,26 @@ describe('registerTools', () => { }); }); }); + +describe('registerTools with a workspace', () => { + it('augments the task with the context pack before startJob', async () => { + const server = new McpServer({ name: 'test-ws', version: '0.0.0' }); + const taskManager = makeMockTaskManager(); + const workspace = { + repoId: '/repo', + getContextPack: vi.fn(() => Promise.resolve({ + files: ['a.ts'], conventions: { testCommand: 'vitest run' }, symbols: [], truncated: false, + })), + refresh: vi.fn(() => Promise.resolve()), + }; + registerTools(server, mockAdapter, taskManager, workspace); + const client = await createConnectedPair(server); + + await callTool(client, 'coding_agent_run', { task: 'refactor auth' }); + + expect(workspace.getContextPack).toHaveBeenCalledWith('refactor auth'); + const passedTask = vi.mocked(taskManager.startJob).mock.calls.at(-1)?.[0] as string; + expect(passedTask).toContain(''); + expect(passedTask.endsWith('refactor auth')).toBe(true); + }); +});