Keep delegated agents out of workspace alerts (#1293)
* Keep delegated agents out of workspace alerts * Share delegated agent label parsing
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||||
|
|
||||||
export function derivePendingPermissionKey(
|
export function derivePendingPermissionKey(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
? new Date(snapshot.attentionTimestamp)
|
? new Date(snapshot.attentionTimestamp)
|
||||||
: null;
|
: null;
|
||||||
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
|
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
|
||||||
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL];
|
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
|
||||||
const parentAgentId =
|
|
||||||
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
|
|
||||||
? parentAgentLabel.trim()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverId,
|
serverId,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import {
|
||||||
|
getParentAgentIdFromLabels,
|
||||||
|
isDelegatedAgent,
|
||||||
|
PARENT_AGENT_ID_LABEL,
|
||||||
|
} from "./agent-labels.js";
|
||||||
|
|
||||||
|
describe("agent label policy", () => {
|
||||||
|
test("treats a non-empty parent agent label as delegation", () => {
|
||||||
|
const labels = { [PARENT_AGENT_ID_LABEL]: " parent-agent \n" };
|
||||||
|
|
||||||
|
expect(getParentAgentIdFromLabels(labels)).toBe("parent-agent");
|
||||||
|
expect(isDelegatedAgent({ labels })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores missing, empty, and non-string parent agent labels", () => {
|
||||||
|
expect(isDelegatedAgent({ labels: {} })).toBe(false);
|
||||||
|
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
|
||||||
|
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1 +1,16 @@
|
|||||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||||
|
|
||||||
|
export interface AgentLabelSource {
|
||||||
|
labels?: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentAgentIdFromLabels(labels: Record<string, unknown> | null | undefined) {
|
||||||
|
const parentAgentId = labels?.[PARENT_AGENT_ID_LABEL];
|
||||||
|
return typeof parentAgentId === "string" && parentAgentId.trim().length > 0
|
||||||
|
? parentAgentId.trim()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
|
||||||
|
return getParentAgentIdFromLabels(agent.labels) !== null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3892,6 +3892,39 @@ test("onAgentAttention is not called for internal agents", async () => {
|
|||||||
expect(attentionCalls).toHaveLength(0);
|
expect(attentionCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("onAgentAttention is not called for delegated child agents", async () => {
|
||||||
|
const childAgentId = "00000000-0000-4000-8000-000000000112";
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
|
const storagePath = join(workdir, "agents");
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const attentionCalls: string[] = [];
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new TestAgentClient(),
|
||||||
|
},
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
idFactory: () => childAgentId,
|
||||||
|
onAgentAttention: ({ agentId }) => {
|
||||||
|
attentionCalls.push(agentId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agent = await manager.createAgent(
|
||||||
|
{
|
||||||
|
provider: "codex",
|
||||||
|
cwd: workdir,
|
||||||
|
title: "Delegated Child Agent",
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.runAgent(agent.id, "hello");
|
||||||
|
|
||||||
|
expect(attentionCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
test("clearAgentAttention on errored agent stays cleared until a new error transition", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
AGENT_LIFECYCLE_STATUSES,
|
AGENT_LIFECYCLE_STATUSES,
|
||||||
type AgentLifecycleStatus,
|
type AgentLifecycleStatus,
|
||||||
} from "@getpaseo/protocol/agent-lifecycle";
|
} from "@getpaseo/protocol/agent-lifecycle";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||||
@@ -3368,6 +3368,10 @@ export class AgentManager {
|
|||||||
agent: ManagedAgent,
|
agent: ManagedAgent,
|
||||||
reason: "finished" | "error" | "permission",
|
reason: "finished" | "error" | "permission",
|
||||||
): void {
|
): void {
|
||||||
|
if (isDelegatedAgent(agent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.onAgentAttention?.({
|
this.onAgentAttention?.({
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
|
import { createTestLogger } from "../test-utils/test-logger.js";
|
||||||
|
import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./messages.js";
|
||||||
|
import { WorkspaceDirectory } from "./workspace-directory.js";
|
||||||
|
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
|
|
||||||
|
const NOW = "2026-03-01T12:00:00.000Z";
|
||||||
|
|
||||||
|
class WorkspaceStatus {
|
||||||
|
private readonly project: PersistedProjectRecord = {
|
||||||
|
projectId: "project-1",
|
||||||
|
rootPath: "/workspace/project",
|
||||||
|
kind: "git",
|
||||||
|
displayName: "project",
|
||||||
|
customName: null,
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly workspace: PersistedWorkspaceRecord = {
|
||||||
|
workspaceId: "workspace-1",
|
||||||
|
projectId: this.project.projectId,
|
||||||
|
cwd: this.project.rootPath,
|
||||||
|
kind: "local_checkout",
|
||||||
|
displayName: "main",
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly agents: AgentSnapshotPayload[] = [];
|
||||||
|
private readonly directory = new WorkspaceDirectory({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
projectRegistry: { list: async () => [this.project] },
|
||||||
|
workspaceRegistry: { list: async () => [this.workspace] },
|
||||||
|
listAgentPayloads: async () => this.agents,
|
||||||
|
isProviderVisibleToClient: () => true,
|
||||||
|
buildWorkspaceDescriptor: async ({ workspace }) => ({
|
||||||
|
id: workspace.workspaceId,
|
||||||
|
projectId: workspace.projectId,
|
||||||
|
projectDisplayName: "project",
|
||||||
|
projectCustomName: null,
|
||||||
|
projectRootPath: this.project.rootPath,
|
||||||
|
workspaceDirectory: workspace.cwd,
|
||||||
|
projectKind: "git",
|
||||||
|
workspaceKind: workspace.kind,
|
||||||
|
name: workspace.displayName,
|
||||||
|
archivingAt: null,
|
||||||
|
status: "done",
|
||||||
|
activityAt: null,
|
||||||
|
diffStat: null,
|
||||||
|
scripts: [],
|
||||||
|
gitRuntime: null,
|
||||||
|
githubRuntime: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
hasRootAgent(input: AgentState): void {
|
||||||
|
this.agents.push(createAgent({ ...input, cwd: this.workspace.cwd }));
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDelegatedAgent(input: AgentState): void {
|
||||||
|
this.agents.push(
|
||||||
|
createAgent({
|
||||||
|
...input,
|
||||||
|
cwd: this.workspace.cwd,
|
||||||
|
labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
|
||||||
|
const entries = await this.directory.listFetchEntries({
|
||||||
|
type: "fetch_workspaces_request",
|
||||||
|
requestId: "workspace-status",
|
||||||
|
});
|
||||||
|
return entries.entries[0]?.status ?? "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentState {
|
||||||
|
id: string;
|
||||||
|
status: AgentSnapshotPayload["status"];
|
||||||
|
pendingPermissionCount?: number;
|
||||||
|
requiresAttention?: boolean;
|
||||||
|
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgent(input: AgentState & { cwd: string; labels?: Record<string, string> }) {
|
||||||
|
const pendingPermissionCount = input.pendingPermissionCount ?? 0;
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
provider: "codex",
|
||||||
|
cwd: input.cwd,
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: null,
|
||||||
|
effectiveThinkingOptionId: null,
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
status: input.status,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: true,
|
||||||
|
supportsMcpServers: true,
|
||||||
|
supportsReasoningStream: true,
|
||||||
|
supportsToolInvocations: true,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: Array.from({ length: pendingPermissionCount }, (_, index) => ({
|
||||||
|
id: `permission-${input.id}-${index}`,
|
||||||
|
provider: "codex",
|
||||||
|
name: "tool",
|
||||||
|
kind: "tool" as const,
|
||||||
|
})),
|
||||||
|
persistence: null,
|
||||||
|
runtimeInfo: {
|
||||||
|
provider: "codex",
|
||||||
|
sessionId: null,
|
||||||
|
},
|
||||||
|
title: null,
|
||||||
|
labels: input.labels ?? {},
|
||||||
|
requiresAttention: input.requiresAttention ?? false,
|
||||||
|
attentionReason: input.attentionReason ?? null,
|
||||||
|
attentionTimestamp: null,
|
||||||
|
archivedAt: null,
|
||||||
|
} satisfies AgentSnapshotPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkspaceDirectory", () => {
|
||||||
|
test("uses root agent activity, not delegated child activity, for workspace status", async () => {
|
||||||
|
const workspace = new WorkspaceStatus();
|
||||||
|
|
||||||
|
workspace.hasRootAgent({ id: "root-agent", status: "running" });
|
||||||
|
workspace.hasDelegatedAgent({
|
||||||
|
id: "child-needs-input",
|
||||||
|
status: "idle",
|
||||||
|
pendingPermissionCount: 1,
|
||||||
|
});
|
||||||
|
workspace.hasDelegatedAgent({
|
||||||
|
id: "child-error",
|
||||||
|
status: "error",
|
||||||
|
requiresAttention: true,
|
||||||
|
attentionReason: "error",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
deriveAgentStateBucket,
|
deriveAgentStateBucket,
|
||||||
getWorkspaceStateBucketPriority,
|
getWorkspaceStateBucketPriority,
|
||||||
} from "@getpaseo/protocol/agent-state-bucket";
|
} from "@getpaseo/protocol/agent-state-bucket";
|
||||||
|
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||||
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
|
|||||||
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isDelegatedAgent(agent)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
||||||
if (workspaceId === undefined) {
|
if (workspaceId === undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user