mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed8fcc17e4 | ||
|
|
e1e48d38fd | ||
|
|
61ac3aa6c0 | ||
|
|
6a0e53f6dc |
@@ -11,6 +11,10 @@ Improved:
|
||||
|
||||
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
Fixed:
|
||||
|
||||
- Keep an open agent on its current provider process when a session-open plugin reloads without changing env, tools, or MCP servers ([#8269](https://github.com/getpaseo/paseo/pull/8269))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||
|
||||
修复:
|
||||
|
||||
- 会话打开钩子重载后如果没有改变 env、工具或 MCP 服务器,就保持当前 provider 进程,不再为下一条消息关掉它 ([#8269](https://github.com/getpaseo/paseo/pull/8269))
|
||||
|
||||
## 0.14.0 - 2026-10-29
|
||||
|
||||
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
@@ -102,6 +103,32 @@ import { withTimeout } from "../../utils/promise-timeout.js";
|
||||
import { extractAttention } from "../persistence-hooks.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
|
||||
interface SessionOpenContribution {
|
||||
env: Record<string, string>;
|
||||
tools: Array<{ pluginId: string; pluginInstanceId: string; name: string }>;
|
||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||
}
|
||||
|
||||
function sessionOpenContribution(input: {
|
||||
purpose: PluginSessionOpenRequest["purpose"];
|
||||
env: Record<string, string> | undefined;
|
||||
tools: readonly AttachedAgentTool[];
|
||||
mcpServers: AgentSessionConfig["mcpServers"];
|
||||
}): SessionOpenContribution {
|
||||
const interactive = input.purpose === "interactive";
|
||||
return {
|
||||
env: { ...input.env },
|
||||
tools: interactive
|
||||
? input.tools.map((tool) => ({
|
||||
pluginId: tool.pluginId,
|
||||
pluginInstanceId: tool.pluginInstanceId,
|
||||
name: tool.name,
|
||||
}))
|
||||
: [],
|
||||
mcpServers: interactive ? structuredClone(input.mcpServers ?? {}) : {},
|
||||
};
|
||||
}
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000;
|
||||
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -780,6 +807,7 @@ export class AgentManager {
|
||||
private readonly mcpAuthToken: string | null;
|
||||
private sessionOpenGeneration = 0;
|
||||
private readonly sessionOpenGenerations = new Map<string, number>();
|
||||
private readonly sessionOpenContributions = new Map<string, SessionOpenContribution>();
|
||||
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
|
||||
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
|
||||
private paseoToolsEnabled = true;
|
||||
@@ -913,11 +941,49 @@ export class AgentManager {
|
||||
await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
if (!this.needsPluginSessionReload(agentId)) return;
|
||||
const generation = this.sessionOpenGeneration;
|
||||
// A hook that only observes the open must not kill the provider process.
|
||||
// Reload only when env, tools, or MCP servers would actually change.
|
||||
const unchanged = await this.sessionOpenContributionUnchanged(agentId);
|
||||
if (!this.needsPluginSessionReload(agentId)) return;
|
||||
if (unchanged && this.sessionOpenGeneration === generation) {
|
||||
this.sessionOpenGenerations.set(agentId, generation);
|
||||
return;
|
||||
}
|
||||
await this.reloadAgentSessionInternal(agentId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async sessionOpenContributionUnchanged(agentId: string): Promise<boolean> {
|
||||
const agent = this.getAgent(agentId);
|
||||
const previous = this.sessionOpenContributions.get(agentId);
|
||||
const client = agent ? this.clients.get(agent.provider) : undefined;
|
||||
if (!agent || !previous || !client || !this.pluginLifecycle) return false;
|
||||
const transformed = await this.pluginLifecycle.before("agent.open.before", {
|
||||
agentId,
|
||||
provider: client.provider,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId ?? null,
|
||||
reason: "refresh",
|
||||
purpose: "interactive",
|
||||
env: { ...agent.launchEnv },
|
||||
title: await this.resolveInitialPersistedTitle(agentId, agent.config, null),
|
||||
labels: agent.labels,
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
});
|
||||
return isDeepStrictEqual(
|
||||
previous,
|
||||
sessionOpenContribution({
|
||||
purpose: "interactive",
|
||||
env: transformed.env,
|
||||
tools: transformed.tools as AttachedAgentTool[],
|
||||
mcpServers: transformed.mcpServers,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private needsPluginSessionReload(agentId: string): boolean {
|
||||
const agent = this.getAgent(agentId);
|
||||
return (
|
||||
@@ -957,6 +1023,7 @@ export class AgentManager {
|
||||
private detachPluginTools(agentId: string): void {
|
||||
this.attachedPluginTools.delete(agentId);
|
||||
this.sessionOpenGenerations.delete(agentId);
|
||||
this.sessionOpenContributions.delete(agentId);
|
||||
this.pluginAgentTools?.detachTools(agentId);
|
||||
}
|
||||
|
||||
@@ -5566,6 +5633,13 @@ export class AgentManager {
|
||||
this.detachPluginTools(agentId);
|
||||
let mcpServers: AgentSessionConfig["mcpServers"];
|
||||
let mcpServerOwners: Record<string, string> | undefined;
|
||||
const purpose = opening?.purpose ?? "interactive";
|
||||
let contribution = sessionOpenContribution({
|
||||
purpose,
|
||||
env,
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
});
|
||||
if (this.pluginLifecycle) {
|
||||
const request = {
|
||||
agentId,
|
||||
@@ -5573,7 +5647,7 @@ export class AgentManager {
|
||||
cwd,
|
||||
workspaceId: opening?.workspaceId ?? null,
|
||||
reason: opening?.reason ?? "resume",
|
||||
purpose: opening?.purpose ?? "interactive",
|
||||
purpose,
|
||||
env: { ...env },
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
@@ -5582,11 +5656,18 @@ export class AgentManager {
|
||||
};
|
||||
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
|
||||
env = transformed.env;
|
||||
const tools = transformed.tools as AttachedAgentTool[];
|
||||
if (request.purpose === "interactive") {
|
||||
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
|
||||
this.attachedPluginTools.set(agentId, tools);
|
||||
mcpServers = transformed.mcpServers;
|
||||
mcpServerOwners = transformed.mcpServerOwners;
|
||||
}
|
||||
contribution = sessionOpenContribution({
|
||||
purpose: request.purpose,
|
||||
env,
|
||||
tools,
|
||||
mcpServers: transformed.mcpServers,
|
||||
});
|
||||
}
|
||||
const context: AgentLaunchContext = {
|
||||
agentId,
|
||||
@@ -5618,6 +5699,7 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
this.sessionOpenGenerations.set(agentId, generation);
|
||||
this.sessionOpenContributions.set(agentId, contribution);
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -667,6 +667,33 @@ test("starting and stopping a session-hook plugin each causes one idle reopen",
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("an unchanged session-open hook does not reopen an idle provider session", async () => {
|
||||
const source = (value: string) => `
|
||||
export default function contribute(server) {
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
return { ...request, env: { ...request.env, STABLE: ${JSON.stringify(value)} } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
const h = await setup(false, source("1"));
|
||||
try {
|
||||
const agent = await h.create();
|
||||
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("1");
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
await sendAndFinish(h, agent.id, "after the same hook");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
await writeFile(path.join(h.directory, "tools-test", "index.server.ts"), source("2"));
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
await sendAndFinish(h, agent.id, "after the hook changes");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("plugins without a session-open hook do not make agent sessions stale", async () => {
|
||||
const h = await setup(true);
|
||||
try {
|
||||
@@ -858,7 +885,7 @@ test("installing and disabling an MCP plugin reopens idle sessions before their
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
|
||||
test("installing and disabling an MCP plugin during a turn leaves the provider session running", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
@@ -885,7 +912,7 @@ test("installing and disabling an MCP plugin never interrupts a running turn", a
|
||||
release();
|
||||
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
|
||||
await sendAndFinish(h, a.id, "idle");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
} finally {
|
||||
release();
|
||||
@@ -1040,3 +1067,57 @@ test("session-open MCP configs are validated before a provider launches", async
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("diagnostic: unchanged observer reload keeps a slow-closing provider usable", async () => {
|
||||
let closes = 0;
|
||||
const h = await setup(
|
||||
false,
|
||||
`
|
||||
export default function contribute(server) {
|
||||
return server.addHook("agent.open.before", async () => {});
|
||||
}
|
||||
`,
|
||||
undefined,
|
||||
{
|
||||
closeSession: async () => {
|
||||
closes++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 3700));
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
const agent = await h.create();
|
||||
await sendAndFinish(h, agent.id, "first");
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
await sendAndFinish(h, agent.id, "after reload");
|
||||
expect(closes).toBe(0);
|
||||
expect(h.launches).toHaveLength(1);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("diagnostic: a changed session-open hook runs once for the replacement process", async () => {
|
||||
const source = (value: string) => `
|
||||
let calls = 0;
|
||||
export default function contribute(server) {
|
||||
return server.addHook("agent.open.before", async ({ request }) => ({
|
||||
...request,
|
||||
env: { ...request.env, STABLE: ${JSON.stringify(value)}, HOOK_CALL: String(++calls) },
|
||||
}));
|
||||
}
|
||||
`;
|
||||
const h = await setup(false, source("1"));
|
||||
try {
|
||||
const agent = await h.create();
|
||||
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("1");
|
||||
await writeFile(path.join(h.directory, "tools-test", "index.server.ts"), source("2"));
|
||||
await h.client.reloadPlugin("tools-test");
|
||||
await sendAndFinish(h, agent.id, "after changed hook");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches.at(-1)!.context!.env!.STABLE).toBe("2");
|
||||
expect(h.launches.at(-1)!.context!.env!.HOOK_CALL).toBe("1");
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
Reference in New Issue
Block a user