Prepare providers for direct Paseo tools (#1707)

* refactor(agent): decouple Paseo tools from MCP

Keep MCP as the fallback adapter while exposing the same runtime catalog for providers that can register tools directly.

* fix(protocol): keep native tool support server-internal

* fix(i18n): sync Japanese shortcut labels

* fix(agent): preserve schedule provider error
This commit is contained in:
Mohamed Boudra
2026-06-25 13:20:51 +08:00
committed by GitHub
parent 53e39e6d23
commit eaecd8d0a7
14 changed files with 3237 additions and 2764 deletions
@@ -33,6 +33,7 @@ import type {
AgentTimelineItem,
ImportProviderSessionInput,
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
interface Deferred<T> {
@@ -1054,6 +1055,84 @@ test("createAgent injects paseo MCP server only into provider launch config", as
});
});
test("createAgent passes native Paseo tools through launch context without internal MCP", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const paseoTools: PaseoToolCatalog = {
tools: new Map(),
getTool: () => undefined,
executeTool: async () => {
throw new Error("No tools registered in test catalog");
},
};
class NativeToolsClient extends TestAgentClient {
override readonly capabilities = {
...TEST_CAPABILITIES,
supportsMcpServers: true,
supportsNativePaseoTools: true,
};
lastConfig: AgentSessionConfig | null = null;
lastLaunchContext: AgentLaunchContext | undefined;
override async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
): Promise<AgentSession> {
this.lastConfig = config;
this.lastLaunchContext = launchContext;
return new TestAgentSession(config);
}
}
const client = new NativeToolsClient();
const manager = new AgentManager({
clients: {
codex: client,
},
registry: storage,
logger,
mcpBaseUrl: "http://127.0.0.1:6767/mcp/agents",
paseoToolCatalogFactory: () => paseoTools,
idFactory: () => "00000000-0000-4000-8000-000000000106",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
mcpServers: {
custom: {
type: "stdio",
command: "custom-mcp",
},
},
});
expect(client.lastLaunchContext?.paseoTools).toBe(paseoTools);
expect(client.lastConfig?.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
expect(snapshot.config.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
const stored = await storage.get(snapshot.id);
expect(stored?.config?.mcpServers).toEqual({
custom: {
type: "stdio",
command: "custom-mcp",
},
});
});
test("createAgent injects the MCP auth token as a bearer header into the launch config", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
@@ -64,6 +64,7 @@ import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -202,6 +203,8 @@ export interface AgentManagerOptions {
terminalManager?: TerminalManager | null;
mcpBaseUrl?: string;
mcpAuthToken?: string;
paseoToolsEnabled?: boolean;
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
appendSystemPrompt?: string;
agentStreamCoalesceWindowMs?: number;
rescueTimeouts?: AgentManagerRescueTimeouts;
@@ -521,6 +524,8 @@ export class AgentManager {
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
private paseoToolsEnabled = true;
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
private appendSystemPrompt: string;
private onAgentAttention?: AgentAttentionCallback;
private onAgentArchived?: AgentArchivedCallback;
@@ -536,6 +541,7 @@ export class AgentManager {
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
this.mcpAuthToken = options?.mcpAuthToken ?? null;
this.configurePaseoTools(options);
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
this.rescueTimeouts = {
@@ -558,6 +564,11 @@ export class AgentManager {
});
}
private configurePaseoTools(options: AgentManagerOptions): void {
this.paseoToolsEnabled = options.paseoToolsEnabled ?? true;
this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null;
}
registerClient(provider: AgentProvider, client: AgentClient): void {
this.clients.set(provider, client);
}
@@ -594,6 +605,14 @@ export class AgentManager {
this.mcpBaseUrl = url;
}
setPaseoToolsEnabled(enabled: boolean): void {
this.paseoToolsEnabled = enabled;
}
setPaseoToolCatalogFactory(factory: PaseoToolCatalogFactory | null): void {
this.paseoToolCatalogFactory = factory;
}
/**
* Capability token the daemon's own MCP clients must present to the Agent MCP
* endpoint when a daemon password is configured. Read by the per-client
@@ -898,12 +917,13 @@ export class AgentManager {
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
const { storedConfig, launchConfig } = await this.prepareSessionConfig(config, resolvedAgentId);
this.requireEnabledProvider(storedConfig.provider);
const launchContext = this.buildLaunchContext(resolvedAgentId, options?.env);
const client = await this.requireAvailableClient({
provider: storedConfig.provider,
});
const launchContext = await this.buildLaunchContext(resolvedAgentId, client, options?.env);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const session = await client.createSession(launchConfig, launchContext, createOptions);
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options?.labels,
initialTitle: options?.initialTitle,
@@ -948,7 +968,6 @@ export class AgentManager {
resolvedAgentId,
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const client = this.requireClient(handle.provider);
const available = await client.isAvailable();
if (!available) {
@@ -956,7 +975,9 @@ export class AgentManager {
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
);
}
const session = await client.resumeSession(handle, launchConfig, launchContext);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = await client.resumeSession(handle, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, options);
}
@@ -982,13 +1003,14 @@ export class AgentManager {
},
resolvedAgentId,
);
const launchContext = this.buildLaunchContext(resolvedAgentId);
const launchContext = await this.buildLaunchContext(resolvedAgentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const imported = await client.importSession(
{
providerHandleId: input.providerHandleId,
cwd: input.cwd,
},
{ config: launchConfig, storedConfig, launchContext },
{ config: providerLaunchConfig, storedConfig, launchContext },
);
const importedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(imported.config));
const timelineRows = buildImportedTimelineRows(imported.timeline);
@@ -1036,11 +1058,12 @@ export class AgentManager {
provider,
} as AgentSessionConfig;
const { storedConfig, launchConfig } = await this.prepareSessionConfig(refreshConfig, agentId);
const launchContext = this.buildLaunchContext(agentId);
const launchContext = await this.buildLaunchContext(agentId, client);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const session = handle
? await client.resumeSession(handle, launchConfig, launchContext)
: await client.createSession(launchConfig, launchContext);
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
this.agentStreamCoalescer.flushAndDiscard(agentId);
// Remove the existing agent entry before swapping sessions
@@ -3696,14 +3719,33 @@ export class AgentManager {
: next;
}
private buildLaunchContext(agentId: string, env?: Record<string, string>): AgentLaunchContext {
return {
private async buildLaunchContext(
agentId: string,
client: AgentClient,
env?: Record<string, string>,
): Promise<AgentLaunchContext> {
const context: AgentLaunchContext = {
agentId,
env: {
...env,
PASEO_AGENT_ID: agentId,
},
};
if (
this.paseoToolsEnabled &&
client.capabilities.supportsNativePaseoTools &&
this.paseoToolCatalogFactory
) {
context.paseoTools = await this.paseoToolCatalogFactory({ callerAgentId: agentId });
}
return context;
}
private resolveProviderLaunchConfig(
launchConfig: AgentSessionConfig,
launchContext: AgentLaunchContext,
): AgentSessionConfig {
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
}
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
@@ -89,6 +89,63 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
return { callTool: boundCallTool, close: () => rawClient.close() };
}
interface LaunchRecorder {
recordedLaunches: AgentSessionConfig[];
}
class RecordingAgentClient implements AgentClient {
readonly provider: AgentClient["provider"];
readonly capabilities: AgentClient["capabilities"];
constructor(
private readonly inner: AgentClient,
private readonly recorder: LaunchRecorder,
) {
this.provider = inner.provider;
this.capabilities = {
...inner.capabilities,
supportsMcpServers: true,
supportsNativePaseoTools: false,
};
}
async createSession(
...args: Parameters<AgentClient["createSession"]>
): ReturnType<AgentClient["createSession"]> {
this.recorder.recordedLaunches.push(args[0]);
return this.inner.createSession(...args);
}
async resumeSession(
...args: Parameters<AgentClient["resumeSession"]>
): ReturnType<AgentClient["resumeSession"]> {
return this.inner.resumeSession(...args);
}
async fetchCatalog(
...args: Parameters<AgentClient["fetchCatalog"]>
): ReturnType<AgentClient["fetchCatalog"]> {
return this.inner.fetchCatalog(...args);
}
async isAvailable(): Promise<boolean> {
return this.inner.isAvailable();
}
}
function createMcpRecordingAgentClients(recorder: LaunchRecorder) {
const clients = createTestAgentClients();
const claude = clients.claude;
if (!claude) {
throw new Error("Fake Claude client is not configured");
}
return {
...clients,
claude: new RecordingAgentClient(claude, recorder),
};
}
async function waitForAgentCompletion(options: {
client: McpClient;
agentId: string;
@@ -256,6 +313,7 @@ describe("agent MCP end-to-end (offline)", () => {
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
const recorder: LaunchRecorder = { recordedLaunches: [] };
const daemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${port}`,
@@ -265,7 +323,7 @@ describe("agent MCP end-to-end (offline)", () => {
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentClients: createMcpRecordingAgentClients(recorder),
agentStoragePath: path.join(paseoHome, "agents"),
};
@@ -278,6 +336,7 @@ describe("agent MCP end-to-end (offline)", () => {
const disabledStaticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-disabled-"));
const disabledAgentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-disabled-"));
const disabledPort = await getAvailablePort();
const disabledRecorder: LaunchRecorder = { recordedLaunches: [] };
const disabledDaemonConfig: PaseoDaemonConfig = {
listen: `127.0.0.1:${disabledPort}`,
paseoHome: disabledPaseoHome,
@@ -287,7 +346,7 @@ describe("agent MCP end-to-end (offline)", () => {
mcpInjectIntoAgents: false,
staticDir: disabledStaticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentClients: createMcpRecordingAgentClients(disabledRecorder),
agentStoragePath: path.join(disabledPaseoHome, "agents"),
};
const disabledDaemon = await createPaseoDaemon(disabledDaemonConfig, pino({ level: "silent" }));
@@ -313,13 +372,14 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
const injectedAgent = daemon.agentManager.getAgent(agentId!);
expect(injectedAgent?.config.mcpServers).toMatchObject({
expect(recorder.recordedLaunches.at(-1)?.mcpServers).toMatchObject({
paseo: {
type: "http",
url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`,
},
});
const injectedAgent = daemon.agentManager.getAgent(agentId!);
expect(injectedAgent?.config.mcpServers?.paseo).toBeUndefined();
const disabledResult = await disabledClient.callTool({
name: "create_agent",
@@ -337,6 +397,7 @@ describe("agent MCP end-to-end (offline)", () => {
typeof disabledPayload?.agentId === "string" ? disabledPayload.agentId : null;
expect(disabledAgentId).toBeTruthy();
expect(disabledRecorder.recordedLaunches.at(-1)?.mcpServers?.paseo).toBeUndefined();
const disabledAgent = disabledDaemon.agentManager.getAgent(disabledAgentId!);
expect(disabledAgent?.config.mcpServers?.paseo).toBeUndefined();
} finally {
@@ -364,6 +425,7 @@ describe("agent MCP end-to-end (offline)", () => {
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const agentCwd = await mkdtemp(path.join(os.tmpdir(), "paseo-agent-cwd-"));
const port = await getAvailablePort();
const recorder: LaunchRecorder = { recordedLaunches: [] };
const daemonConfig: PaseoDaemonConfig = {
listen: `0.0.0.0:${port}`,
@@ -373,7 +435,7 @@ describe("agent MCP end-to-end (offline)", () => {
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentClients: createMcpRecordingAgentClients(recorder),
agentStoragePath: path.join(paseoHome, "agents"),
};
@@ -399,13 +461,14 @@ describe("agent MCP end-to-end (offline)", () => {
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
const injectedAgent = daemon.agentManager.getAgent(agentId!);
expect(injectedAgent?.config.mcpServers).toMatchObject({
expect(recorder.recordedLaunches.at(-1)?.mcpServers).toMatchObject({
paseo: {
type: "http",
url: `http://127.0.0.1:${port}/mcp/agents?callerAgentId=${agentId!}`,
},
});
const injectedAgent = daemon.agentManager.getAgent(agentId!);
expect(injectedAgent?.config.mcpServers?.paseo).toBeUndefined();
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
@@ -568,6 +631,23 @@ describe("agent MCP end-to-end (offline)", () => {
return true;
}
async fetchCatalog(): Promise<{
models: Array<{ provider: "codex"; id: string; label: string; isDefault: boolean }>;
modes: Array<{ id: string; label: string; description: string }>;
}> {
return {
models: [
{
provider: "codex",
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
isDefault: true,
},
],
modes: [{ id: "full-access", label: "Full access", description: "No prompts" }],
};
}
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
return new StartTurnFailureSession();
}
@@ -605,6 +685,7 @@ describe("agent MCP end-to-end (offline)", () => {
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
let agentId: string | null = null;
try {
const result = await client.callTool({
name: "create_agent",
@@ -618,14 +699,25 @@ describe("agent MCP end-to-end (offline)", () => {
},
});
expect(result.isError).toBe(true);
const contentItem = result.content?.[0];
const contentText: string | undefined =
contentItem != null && typeof contentItem === "object"
? Reflect.get(contentItem, "text")
: undefined;
expect(contentText ?? "").toContain("Initial turn failed to start");
const payload = getStructuredContent(result);
agentId = typeof payload?.agentId === "string" ? payload.agentId : null;
expect(agentId).toBeTruthy();
await waitForAgentCompletion({ client, agentId: agentId! });
const statusResult = await client.callTool({
name: "get_agent_status",
args: { agentId },
});
const statusPayload = getStructuredContent(statusResult);
expect(statusPayload?.status).toBe("error");
const snapshot = statusPayload?.snapshot;
const lastError =
snapshot && typeof snapshot === "object" ? Reflect.get(snapshot, "lastError") : undefined;
expect(lastError).toContain("Initial turn failed to start");
} finally {
if (agentId) {
await client.callTool({ name: "kill_agent", args: { agentId } });
}
await client.close();
await daemon.stop();
await rm(paseoHome, { recursive: true, force: true });
@@ -1,6 +1,7 @@
import type { Options as ClaudeAgentOptions } from "@anthropic-ai/claude-agent-sdk";
import type { AgentProviderNotice } from "@getpaseo/protocol/agent-types";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type { AgentProviderNotice };
@@ -169,6 +170,7 @@ export interface AgentCapabilityFlags {
supportsSessionListing?: boolean;
supportsDynamicModes: boolean;
supportsMcpServers: boolean;
supportsNativePaseoTools?: boolean;
supportsReasoningStream: boolean;
supportsToolInvocations: boolean;
supportsRewindConversation?: boolean;
@@ -577,6 +579,11 @@ export interface AgentSessionConfig {
export interface AgentLaunchContext {
agentId?: string;
env?: Record<string, string>;
/**
* Runtime-only internal Paseo tools. This must never be persisted into
* AgentSessionConfig; providers may adapt it to their native tool surface.
*/
paseoTools?: PaseoToolCatalog;
}
export interface AgentCreateSessionOptions {
@@ -722,7 +722,7 @@ describe("create_agent MCP tool", () => {
).toBe(true);
});
it("requires an explicit workspace", async () => {
it("rejects partial explicit workspace shape", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
@@ -740,12 +740,10 @@ describe("create_agent MCP tool", () => {
initialPrompt: "test",
});
expect(parsed.success).toBe(false);
expect(
parsed.error.issues.some(
(issue: { path: Array<string | number> }) => issue.path[0] === "workspace",
),
).toBe(true);
expect(parsed.success).toBe(true);
await expect(tool.handler(parsed.data)).rejects.toThrow(
"relationship and workspace must be provided together",
);
});
it("rejects caller-only relationship and workspace intents without a caller agent", async () => {
@@ -765,7 +763,7 @@ describe("create_agent MCP tool", () => {
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("relationship subagent requires an agent-scoped MCP session");
).rejects.toThrow("relationship subagent requires an agent-scoped tool session");
await expect(
tool.handler({
@@ -774,7 +772,7 @@ describe("create_agent MCP tool", () => {
provider: "codex/gpt-5.4",
initialPrompt: "test",
}),
).rejects.toThrow("workspace current requires an agent-scoped MCP session");
).rejects.toThrow("workspace current requires an agent-scoped tool session");
});
it("requires a caller workspace for current workspace intent", async () => {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import type { z } from "zod";
export interface PaseoToolExecutionContext {
signal?: AbortSignal;
}
export interface PaseoToolResult {
content: Array<{ type: string; text?: string; [key: string]: unknown }>;
structuredContent?: unknown;
isError?: boolean;
}
export interface PaseoToolConfig {
title?: string;
description?: string;
inputSchema?: z.ZodRawShape | z.ZodType;
outputSchema?: z.ZodRawShape | z.ZodType;
}
export interface PaseoToolDefinition extends PaseoToolConfig {
name: string;
description: string;
handler: (input: unknown, context: PaseoToolExecutionContext) => Promise<PaseoToolResult>;
}
export interface PaseoToolCatalog {
tools: ReadonlyMap<string, PaseoToolDefinition>;
getTool(name: string): PaseoToolDefinition | undefined;
executeTool(
name: string,
input: unknown,
context?: PaseoToolExecutionContext,
): Promise<PaseoToolResult>;
}
export interface PaseoToolRuntimeContext {
callerAgentId?: string;
enableVoiceTools?: boolean;
voiceOnly?: boolean;
}
export type PaseoToolCatalogFactory = (
context: PaseoToolRuntimeContext,
) => PaseoToolCatalog | Promise<PaseoToolCatalog>;
+91 -70
View File
@@ -105,6 +105,11 @@ import { AgentManager } from "./agent/agent-manager.js";
import { AgentStorage } from "./agent/agent-storage.js";
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
import { createAgentMcpServer } from "./agent/mcp-server.js";
import {
createPaseoToolCatalog,
type PaseoToolHostDependencies,
} from "./agent/tools/paseo-tools.js";
import type { PaseoToolRuntimeContext } from "./agent/tools/types.js";
import { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js";
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
@@ -858,82 +863,96 @@ export async function createPaseoDaemon(
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
});
const createPaseoWorktreeForTools = async (
input: Parameters<typeof createPaseoWorktreeWorkflow>[1],
serviceOptions?: Parameters<typeof createPaseoWorktreeWorkflow>[2],
) => {
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
getDaemonTcpHost: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.host : null),
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
},
input,
serviceOptions,
);
};
const createAgentToolHostDependencies = (
runtime: PaseoToolRuntimeContext,
): PaseoToolHostDependencies => ({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
github,
workspaceGitService,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
createPaseoWorktree: createPaseoWorktreeForTools,
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId: runtime.callerAgentId,
enableVoiceTools: runtime.enableVoiceTools,
voiceOnly: runtime.voiceOnly,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
const createAgentToolCatalog = (runtime: PaseoToolRuntimeContext) =>
createPaseoToolCatalog(createAgentToolHostDependencies(runtime));
agentManager.setPaseoToolCatalogFactory(createAgentToolCatalog);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
const mcpEnabled = config.mcpEnabled ?? true;
let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents";
const createAgentMcpSession = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({
agentManager,
agentStorage,
terminalManager,
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
github,
workspaceGitService,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
createPaseoWorktree: async (input, serviceOptions) => {
return createPaseoWorktreeWorkflow(
{
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
createPaseoWorktree: async (workflowInput, workflowOptions) => {
return createRegisteredPaseoWorktree(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? {
resolveDefaultBranch: workflowOptions.resolveDefaultBranch,
}
: {}),
projectRegistry,
workspaceRegistry,
workspaceGitService,
});
},
warmWorkspaceGitData: async (workspace) => {
await Promise.all(
wsServer
?.listActiveSessions()
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
cacheWorkspaceSetupSnapshot: () => {},
emit: emitExternalSessionMessage,
sessionLogger: logger,
terminalManager,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
serviceProxy,
scriptRuntimeStore,
getDaemonTcpPort: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.port : null,
getDaemonTcpHost: () =>
boundListenTarget?.type === "tcp" ? boundListenTarget.host : null,
serviceProxyPublicBaseUrl,
onScriptsChanged: null,
},
input,
serviceOptions,
);
},
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
callerAgentId,
enableVoiceTools: false,
resolveSpeakHandler: (agentId) => wsServer?.resolveVoiceSpeakHandler(agentId) ?? null,
resolveCallerContext: (agentId) => wsServer?.resolveVoiceCallerContext(agentId) ?? null,
logger,
});
const agentMcpServer = await createAgentMcpServer(
createAgentToolHostDependencies({ callerAgentId }),
);
// Stateless mode: each HTTP request builds a fresh server + transport that is
// torn down when the response closes, so no per-session state is retained between
@@ -1087,8 +1106,10 @@ export async function createPaseoDaemon(
const mcpBaseUrl = mcpEnabled ? createAgentMcpBaseUrl(boundListenTarget) : null;
agentMcpBaseUrl = config.mcpInjectIntoAgents === false ? null : mcpBaseUrl;
agentManager.setMcpBaseUrl(agentMcpBaseUrl);
agentManager.setPaseoToolsEnabled(config.mcpInjectIntoAgents !== false);
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
agentManager.setPaseoToolsEnabled(value !== false);
});
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
-53
View File
@@ -3,7 +3,6 @@ import { v4 as uuidv4 } from "uuid";
import { stat } from "node:fs/promises";
import { basename, normalize, resolve, sep } from "path";
import { homedir } from "node:os";
import type { ToolSet } from "ai";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
import {
serializeAgentStreamEvent,
@@ -49,8 +48,6 @@ import {
resolveFirstAgentPromptTitle,
} from "./agent/create-agent-title.js";
import { respondToAgentPermission } from "./agent/permission-response.js";
import { experimental_createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "./voice-types.js";
import type { ScriptHealthState } from "./script-health-monitor.js";
import { spawnWorkspaceScript } from "./worktree-bootstrap.js";
@@ -537,9 +534,6 @@ export class Session {
private readonly paseoHome: string;
private readonly worktreesRoot: string | undefined;
// Per-session MCP client and tools
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null;
private agentTools: ToolSet | null = null;
private agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
@@ -552,7 +546,6 @@ export class Session {
private readonly gitMutation: GitMutationService;
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly mcpBaseUrl: string | null;
private readonly pushTokenStore: PushTokenStore;
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
@@ -619,7 +612,6 @@ export class Session {
generateWorkspaceName,
workspaceGitService,
daemonConfigStore,
mcpBaseUrl,
stt,
sttLanguage,
tts,
@@ -791,7 +783,6 @@ export class Session {
logger: this.sessionLogger,
});
this.daemonConfigStore = daemonConfigStore;
this.mcpBaseUrl = mcpBaseUrl ?? null;
this.terminalManager = terminalManager;
this.terminalController = new TerminalSessionController({
terminalManager,
@@ -908,8 +899,6 @@ export class Session {
dictation,
});
// Initialize agent MCP client asynchronously
void this.initializeAgentMcp();
this.subscribeToAgentEvents();
this.sessionLogger.trace({}, "agent.session.lifecycle.created");
@@ -1118,37 +1107,6 @@ export class Session {
});
}
/**
* Initialize Agent MCP client for this session using the daemon's HTTP MCP endpoint.
*/
private async initializeAgentMcp(): Promise<void> {
try {
if (!this.mcpBaseUrl) {
this.sessionLogger.info(
"Skipping Agent MCP initialization because no MCP base URL is configured",
);
return;
}
const authToken = this.agentManager.getMcpAuthToken();
const transport = new StreamableHTTPClientTransport(
new URL(this.mcpBaseUrl),
authToken
? { requestInit: { headers: { Authorization: `Bearer ${authToken}` } } }
: undefined,
);
this.agentMcpClient = await experimental_createMCPClient({
transport,
});
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet;
const agentToolCount = Object.keys(this.agentTools ?? {}).length;
this.sessionLogger.trace({ agentToolCount }, "agent.session.mcp_init");
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to initialize Agent MCP");
}
}
/**
* Subscribe to AgentManager events and forward them to the client
*/
@@ -5667,17 +5625,6 @@ export class Session {
await this.voiceSession.cleanup();
// Close MCP clients
if (this.agentMcpClient) {
try {
await this.agentMcpClient.close();
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to close Agent MCP client");
}
this.agentMcpClient = null;
this.agentTools = null;
}
this.terminalController.dispose();
this.checkoutSession.cleanup();
@@ -37,6 +37,13 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
};
const TEST_FEATURE_ID = "test_feature";
const TEST_MODES: AgentMode[] = [
{ id: "bypassPermissions", label: "Bypass", description: "No permissions" },
{ id: "default", label: "Default", description: "Ask for permissions" },
{ id: "full-access", label: "Full access", description: "No prompts" },
{ id: "auto", label: "Auto", description: "Ask/allow based on policy" },
{ id: "always-ask", label: "Always Ask", description: "Always prompt" },
];
interface Deferred<T> {
promise: Promise<T>;
@@ -798,13 +805,7 @@ class FakeAgentSession implements AgentSession {
}
async getAvailableModes(): Promise<AgentMode[]> {
return [
{ id: "bypassPermissions", label: "Bypass", description: "No permissions" },
{ id: "default", label: "Default", description: "Ask for permissions" },
{ id: "full-access", label: "Full access", description: "No prompts" },
{ id: "auto", label: "Auto", description: "Ask/allow based on policy" },
{ id: "always-ask", label: "Always Ask", description: "Always prompt" },
];
return TEST_MODES;
}
async getCurrentMode(): Promise<string | null> {
@@ -1194,7 +1195,7 @@ class FakeAgentClient implements AgentClient {
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
{ provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false },
],
modes: [],
modes: TEST_MODES,
};
}
if (this.provider === "codex") {
@@ -1207,12 +1208,12 @@ class FakeAgentClient implements AgentClient {
isDefault: true,
},
],
modes: [],
modes: TEST_MODES,
};
}
return {
models: [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }],
modes: [],
modes: TEST_MODES,
};
}