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 { 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(
|
||||
agentId: string,
|
||||
@@ -26,11 +26,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
? new Date(snapshot.attentionTimestamp)
|
||||
: null;
|
||||
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
|
||||
const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL];
|
||||
const parentAgentId =
|
||||
typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0
|
||||
? parentAgentLabel.trim()
|
||||
: null;
|
||||
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
|
||||
|
||||
return {
|
||||
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 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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-error-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} 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 { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -3368,6 +3368,10 @@ export class AgentManager {
|
||||
agent: ManagedAgent,
|
||||
reason: "finished" | "error" | "permission",
|
||||
): void {
|
||||
if (isDelegatedAgent(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onAgentAttention?.({
|
||||
agentId: agent.id,
|
||||
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,
|
||||
getWorkspaceStateBucketPriority,
|
||||
} from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { normalizeWorkspaceId } from "./workspace-registry-model.js";
|
||||
@@ -187,6 +188,9 @@ export class WorkspaceDirectory {
|
||||
if (!this.deps.isProviderVisibleToClient(agent.provider)) {
|
||||
continue;
|
||||
}
|
||||
if (isDelegatedAgent(agent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd));
|
||||
if (workspaceId === undefined) {
|
||||
|
||||
Reference in New Issue
Block a user