Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions extensions/pagespace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ export default async function (pi: ExtensionAPI) {
config.conversationId = globalThis.crypto.randomUUID();
const api = new PageSpaceApi(config);

// Auto-discover agents from the page tree when PAGESPACE_MODEL_PAGES is not explicitly set.
// Uses AI_CHAT page titles as display names — accurate, no dependence on /api/v1/models name field.
if (!process.env.PAGESPACE_MODEL_PAGES?.trim() && config.defaultDriveSlug) {
// Auto-discover agents from every drive the token can access (default drive's agents first)
// when PAGESPACE_MODEL_PAGES is not explicitly set. Uses AI_CHAT page titles as display names —
// accurate, no dependence on /api/v1/models name field.
if (!process.env.PAGESPACE_MODEL_PAGES?.trim()) {
try {
const discovered = await api.listAgentsByDriveSlug(config.defaultDriveSlug);
const discovered = await api.listAgentsAllDrives(config.defaultDriveSlug);
if (discovered.length > 0) {
const primary = config.modelPageId;
const rest = discovered.filter((m) => m.id !== primary);
Expand Down
60 changes: 45 additions & 15 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ export interface TaskRecord {
[key: string]: unknown;
}

export interface AgentSpec {
id: string;
name: string;
}

/** All AI_CHAT pages in a page tree, depth-first, with page titles as names. Pure. */
export function collectAgentPages(pages: Page[]): AgentSpec[] {
const out: AgentSpec[] = [];
const walk = (nodes: Page[]) => {
for (const p of nodes) {
if (p.type === "AI_CHAT") out.push({ id: p.id, name: p.title });
if (p.children) walk(p.children);
}
};
walk(pages);
return out;
}

/** Preferred drive first, others in given order. Pure. */
export function orderDrivesPreferredFirst(drives: Drive[], preferredSlug?: string): Drive[] {
if (!preferredSlug) return drives;
return [...drives].sort((a, b) => (a.slug === preferredSlug ? -1 : b.slug === preferredSlug ? 1 : 0));
}

/**
* Disambiguate duplicate agent names by suffixing a short page-id fragment — pi model ids are
* the display names, so duplicates across drives would otherwise collide in the registry. Pure.
*/
export function dedupeAgentNames(agents: AgentSpec[]): AgentSpec[] {
const counts = new Map<string, number>();
for (const a of agents) counts.set(a.name, (counts.get(a.name) ?? 0) + 1);
return agents.map((a) =>
(counts.get(a.name) ?? 0) > 1 ? { ...a, name: `${a.name} (${a.id.slice(0, 8)})` } : a,
);
Comment on lines +81 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure generated agent names are globally unique

When an existing agent is already titled like a generated name (for example, Curator (abcdefgh) alongside two agents named Curator), or two duplicate-name agents share the same eight-character ID prefix, this produces duplicate pi model IDs. Selecting the later model then still routes through config.models.find(...) to the first matching entry, so requests can be sent to the wrong PageSpace agent; generate suffixes while checking the complete resulting name set rather than only counting the original titles.

Useful? React with 👍 / 👎.

}

/** Transient HTTP statuses worth retrying (rate-limit + gateway/availability). */
const RETRIABLE_STATUS = new Set([429, 502, 503, 504]);

Expand Down Expand Up @@ -119,21 +155,15 @@ export class PageSpaceApi {
return this.request<Page[]>("GET", `/api/drives/${driveId}/pages`);
}

/** All AI_CHAT pages in the drive identified by slug, with their page titles as names. */
async listAgentsByDriveSlug(driveSlug: string): Promise<{ id: string; name: string }[]> {
const drives = await this.listDrives();
const drive = drives.find((d) => d.slug === driveSlug);
if (!drive) return [];
const pages = await this.listPages(drive.id);
const out: { id: string; name: string }[] = [];
const walk = (nodes: Page[]) => {
for (const p of nodes) {
if (p.type === "AI_CHAT") out.push({ id: p.id, name: p.title });
if (p.children) walk(p.children);
}
};
walk(pages);
return out;
/**
* All AI_CHAT pages across every drive the token can access, preferred drive's agents first.
* A drive whose page tree fails to load is skipped — discovery degrades, never throws.
*/
async listAgentsAllDrives(preferredSlug?: string): Promise<AgentSpec[]> {
const drives = orderDrivesPreferredFirst(await this.listDrives(), preferredSlug);
const trees = await Promise.allSettled(drives.map((d) => this.listPages(d.id)));
const agents = trees.flatMap((t) => (t.status === "fulfilled" ? collectAgentPages(t.value) : []));
return dedupeAgentNames(agents);
}

createPage(input: {
Expand Down
74 changes: 74 additions & 0 deletions test/unit/agents.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectAgentPages,
dedupeAgentNames,
orderDrivesPreferredFirst,
type Drive,
type Page,
} from "../../src/api.ts";

const page = (id: string, title: string, type: string, children?: Page[]): Page => ({
id,
title,
type,
parentId: null,
children,
});

test("collectAgentPages: finds AI_CHAT pages at any depth, in tree order, titles as names", () => {
const tree: Page[] = [
page("a", "Top Agent", "AI_CHAT"),
page("f", "Folder", "FOLDER", [
page("doc", "Doc", "DOCUMENT"),
page("b", "Nested Agent", "AI_CHAT", [page("c", "Deep Agent", "AI_CHAT")]),
]),
];
assert.deepEqual(collectAgentPages(tree), [
{ id: "a", name: "Top Agent" },
{ id: "b", name: "Nested Agent" },
{ id: "c", name: "Deep Agent" },
]);
});

test("collectAgentPages: no agents → empty list", () => {
assert.deepEqual(collectAgentPages([page("d", "Doc", "DOCUMENT")]), []);
});

test("orderDrivesPreferredFirst: preferred drive moves to front, others keep order", () => {
const drives: Drive[] = [
{ id: "1", name: "A", slug: "a" },
{ id: "2", name: "B", slug: "b" },
{ id: "3", name: "C", slug: "c" },
];
assert.deepEqual(
orderDrivesPreferredFirst(drives, "b").map((d) => d.slug),
["b", "a", "c"],
);
// No preference (or unknown slug) → unchanged order, original array untouched.
assert.deepEqual(
orderDrivesPreferredFirst(drives).map((d) => d.slug),
["a", "b", "c"],
);
assert.deepEqual(
orderDrivesPreferredFirst(drives, "zzz").map((d) => d.slug),
["a", "b", "c"],
);
assert.deepEqual(
drives.map((d) => d.slug),
["a", "b", "c"],
);
});

test("dedupeAgentNames: duplicate names get a short id suffix; unique names untouched", () => {
const agents = [
{ id: "abcdefgh1234", name: "Curator" },
{ id: "ijklmnop5678", name: "Curator" },
{ id: "qrstuvwx9012", name: "Writer" },
];
assert.deepEqual(dedupeAgentNames(agents), [
{ id: "abcdefgh1234", name: "Curator (abcdefgh)" },
{ id: "ijklmnop5678", name: "Curator (ijklmnop)" },
{ id: "qrstuvwx9012", name: "Writer" },
]);
});
Loading