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:
@@ -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>;
|
||||
@@ -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 : "");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user