Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 13 additions & 2 deletions src/agent-task-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,28 @@ 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';

export class AgentTaskExecutor implements AgentExecutor {
private readonly _config: Config;
private readonly _router: Router;
private readonly _workspace: Workspace | undefined;
private readonly _activeRunners = new Map<string, Runner>();

/**
* @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<void> => {
Expand Down Expand Up @@ -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<void>((resolve) => {
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -112,6 +113,7 @@ function envVals(): Record<string, unknown> {
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,
Expand Down
27 changes: 27 additions & 0 deletions src/context/augment.ts
Original file line number Diff line number Diff line change
@@ -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 `<workspace-context>\n${sections.join('\n\n')}\n</workspace-context>\n\n${task}`;
}
14 changes: 14 additions & 0 deletions src/context/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 2 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 };
}
10 changes: 8 additions & 2 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand Down
22 changes: 22 additions & 0 deletions tests/unit/agent-task-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<workspace-context>');
expect(call.task).toContain('Test command: vitest run');
expect(call.task.endsWith('do stuff')).toBe(true);
});
});
41 changes: 41 additions & 0 deletions tests/unit/context/augment.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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('<workspace-context>');
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)');
});
});
18 changes: 18 additions & 0 deletions tests/unit/context/create-workspace.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
23 changes: 23 additions & 0 deletions tests/unit/mcp/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<workspace-context>');
expect(passedTask.endsWith('refactor auth')).toBe(true);
});
});
Loading