mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat(server): self-identification tools
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||
return new TestAgentSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestAgentSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return {
|
||||
sessionId: this.id ?? this.config.provider,
|
||||
finalText: "",
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "ignore" });
|
||||
execSync('git config user.email "paseo-test@example.com"', {
|
||||
cwd: repoDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync('git config user.name "Paseo Test"', {
|
||||
cwd: repoDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(path.join(repoDir, "README.md"), "init\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: repoDir, stdio: "ignore" });
|
||||
}
|
||||
|
||||
describe("self-identification MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("set_branch renames branch for Paseo worktree", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const worktree = await createWorktree({
|
||||
branchName: "self-ident",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "self-ident",
|
||||
});
|
||||
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await tool.callback({ name: "self-ident-ready" });
|
||||
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(branch).toBe("self-ident-ready");
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const worktree = await createWorktree({
|
||||
branchName: "self-ident-subdir",
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "self-ident-subdir",
|
||||
});
|
||||
const nestedDir = path.join(worktree.worktreePath, "nested");
|
||||
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
|
||||
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident-subdir",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: nestedDir,
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await tool.callback({ name: "self-ident-subdir-ready" });
|
||||
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: nestedDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(branch).toBe("self-ident-subdir-ready");
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch rejects non-Paseo checkouts", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
logger,
|
||||
idFactory: () => "agent-non-worktree",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await expect(tool.callback({ name: "should-fail" })).rejects.toMatchObject({
|
||||
code: "NOT_ALLOWED",
|
||||
});
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch rejects non-git directories", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
const registryPath = path.join(repoDir, "agents.json");
|
||||
const registry = new AgentRegistry(registryPath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry,
|
||||
logger,
|
||||
idFactory: () => "agent-non-git",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager: manager,
|
||||
agentRegistry: registry,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await expect(tool.callback({ name: "nope" })).rejects.toMatchObject({
|
||||
code: "NOT_GIT_REPO",
|
||||
});
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -101,4 +101,26 @@ describe("create_agent MCP tool", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("set_title trims and persists titles for caller agent", async () => {
|
||||
const { agentManager, agentRegistry, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "agent-1",
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentRegistry,
|
||||
logger,
|
||||
callerAgentId: "agent-1",
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_title"];
|
||||
|
||||
await tool.callback({ title: " Fix auth " });
|
||||
|
||||
expect(spies.agentRegistry.setTitle).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"Fix auth"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,8 +25,13 @@ import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentRegistry } from "./agent-registry.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import {
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
validateBranchSlug,
|
||||
} from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { NotGitRepoError, renameCurrentBranch } from "../../utils/checkout-git.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -105,6 +110,18 @@ function expandPath(path: string): string {
|
||||
return resolve(path);
|
||||
}
|
||||
|
||||
type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH";
|
||||
|
||||
class AgentMcpToolError extends Error {
|
||||
readonly code: ToolErrorCode;
|
||||
|
||||
constructor(code: ToolErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "AgentMcpToolError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps agentManager.waitForAgentEvent with a self-imposed timeout.
|
||||
* Returns a friendly message when timeout occurs, rather than letting
|
||||
@@ -880,6 +897,134 @@ export async function createAgentMcpServer(
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_title",
|
||||
{
|
||||
title: "Set Agent Title",
|
||||
description: "Update the agent's title in the registry.",
|
||||
inputSchema: {
|
||||
title: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(40)
|
||||
.describe("Short descriptive title (<= 40 chars)."),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ title }) => {
|
||||
if (!callerAgentId) {
|
||||
throw new AgentMcpToolError(
|
||||
"NOT_ALLOWED",
|
||||
"set_title can only be called by a managed agent"
|
||||
);
|
||||
}
|
||||
|
||||
const agent = agentManager.getAgent(callerAgentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${callerAgentId} not found`);
|
||||
}
|
||||
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
throw new AgentMcpToolError("NOT_ALLOWED", "Title cannot be empty");
|
||||
}
|
||||
if (normalizedTitle.length > 40) {
|
||||
throw new AgentMcpToolError(
|
||||
"NOT_ALLOWED",
|
||||
"Title must be 40 characters or fewer"
|
||||
);
|
||||
}
|
||||
|
||||
await agentRegistry.setTitle(agent.id, normalizedTitle);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
success: true,
|
||||
title: normalizedTitle,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_branch",
|
||||
{
|
||||
title: "Set Agent Branch",
|
||||
description:
|
||||
"Rename the current git branch. Allowed only inside Paseo-owned worktrees.",
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Git branch name (lowercase letters, numbers, hyphens, slashes)."),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
branch: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ name }) => {
|
||||
if (!callerAgentId) {
|
||||
throw new AgentMcpToolError(
|
||||
"NOT_ALLOWED",
|
||||
"set_branch can only be called by a managed agent"
|
||||
);
|
||||
}
|
||||
|
||||
const agent = agentManager.getAgent(callerAgentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${callerAgentId} not found`);
|
||||
}
|
||||
|
||||
const validation = validateBranchSlug(name);
|
||||
if (!validation.valid) {
|
||||
throw new AgentMcpToolError(
|
||||
"INVALID_BRANCH",
|
||||
validation.error ?? "Invalid branch name"
|
||||
);
|
||||
}
|
||||
|
||||
let ownership;
|
||||
try {
|
||||
ownership = await isPaseoOwnedWorktreeCwd(agent.cwd);
|
||||
} catch (error) {
|
||||
const notGitError =
|
||||
error instanceof NotGitRepoError
|
||||
? error
|
||||
: new NotGitRepoError(agent.cwd);
|
||||
throw new AgentMcpToolError(
|
||||
"NOT_GIT_REPO",
|
||||
notGitError.message
|
||||
);
|
||||
}
|
||||
|
||||
if (!ownership.allowed) {
|
||||
throw new AgentMcpToolError(
|
||||
"NOT_ALLOWED",
|
||||
"Branch renames are only allowed inside Paseo-owned worktrees"
|
||||
);
|
||||
}
|
||||
|
||||
const result = await renameCurrentBranch(agent.cwd, name);
|
||||
if (result.currentBranch !== name) {
|
||||
throw new Error(
|
||||
`Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
success: true,
|
||||
branch: name,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_agent_mode",
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { getOrchestratorModeInstructions } from "../orchestrator-instructions.js";
|
||||
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
|
||||
|
||||
const fsPromises = promises;
|
||||
|
||||
@@ -801,7 +802,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
systemPrompt: {
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: getOrchestratorModeInstructions(),
|
||||
append: [
|
||||
getOrchestratorModeInstructions(),
|
||||
this.currentMode === "plan" ? "" : getSelfIdentificationInstructions(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
},
|
||||
settingSources: ["user", "project"],
|
||||
stderr: (data: string) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { getSelfIdentificationInstructions } from "../self-identification-instructions.js";
|
||||
|
||||
type CodexMcpAgentConfig = AgentSessionConfig & { provider: "codex" };
|
||||
|
||||
@@ -2757,6 +2758,7 @@ function buildCodexMcpConfig(
|
||||
developerInstructions = history;
|
||||
}
|
||||
}
|
||||
const selfIdentificationInstructions = getSelfIdentificationInstructions();
|
||||
|
||||
// Build MCP servers configuration
|
||||
const mcpServers: Record<string, CodexMcpServerConfig> = {};
|
||||
@@ -2830,9 +2832,12 @@ function buildCodexMcpConfig(
|
||||
}
|
||||
|
||||
|
||||
// Add developer instructions for session resume context
|
||||
if (developerInstructions) {
|
||||
configPayload["developer-instructions"] = developerInstructions;
|
||||
const combinedDeveloperInstructions = [developerInstructions, selfIdentificationInstructions]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
|
||||
if (combinedDeveloperInstructions) {
|
||||
configPayload["developer-instructions"] = combinedDeveloperInstructions;
|
||||
}
|
||||
|
||||
return configPayload;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export function getSelfIdentificationInstructions(): string {
|
||||
return [
|
||||
"Once you understand the task and are about to execute multi-step work (e.g., edits, tests, or repo changes), call set_title and set_branch exactly once to self-identify.",
|
||||
"Skip these calls in plan/read-only modes or when running a slash command.",
|
||||
"Do not call set_title or set_branch for short, fixed, or single-response requests (e.g., “say X exactly”, yes/no, one-liners).",
|
||||
"Only call set_branch when you are certain you are running inside a Paseo-owned worktree under .paseo/worktrees.",
|
||||
"If set_branch fails due to permissions or not being in a Paseo worktree, do not retry.",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -43,7 +43,6 @@ import { OpenAISTT, type STTConfig } from "./agent/stt-openai.js";
|
||||
import { OpenAITTS, type TTSConfig } from "./agent/tts-openai.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentRegistry } from "./agent/agent-registry.js";
|
||||
import { initializeTitleGenerator } from "../services/agent-title-generator.js";
|
||||
import { attachAgentRegistryPersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||
@@ -367,7 +366,6 @@ export async function createPaseoDaemon(
|
||||
);
|
||||
}
|
||||
|
||||
initializeTitleGenerator(logger.child({ module: "agent-title-generator" }), openaiApiKey);
|
||||
} else {
|
||||
logger.warn("OPENAI_API_KEY not set - LLM, STT, and TTS features will not work");
|
||||
}
|
||||
|
||||
@@ -63,10 +63,6 @@ import {
|
||||
} from "./file-explorer/service.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import {
|
||||
generateAgentTitle,
|
||||
isTitleGeneratorInitialized,
|
||||
} from "../services/agent-title-generator.js";
|
||||
import { createWorktree, slugify, validateBranchSlug } from "../utils/worktree.js";
|
||||
import {
|
||||
getCheckoutDiff,
|
||||
@@ -86,7 +82,6 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_OPTIONAL_LOCKS: "0",
|
||||
};
|
||||
const ACTIVE_TITLE_GENERATIONS = new Set<string>();
|
||||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>();
|
||||
let restartRequested = false;
|
||||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0];
|
||||
@@ -249,7 +244,6 @@ export class Session {
|
||||
private readonly downloadTokenStore: DownloadTokenStore;
|
||||
private readonly pushTokenStore: PushTokenStore;
|
||||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>;
|
||||
private agentTitleCache: Map<string, string | null> = new Map();
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
private clientActivity: {
|
||||
deviceType: "web" | "mobile";
|
||||
@@ -529,12 +523,7 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
event.event.type === "timeline" ||
|
||||
event.event.type === "turn_completed"
|
||||
) {
|
||||
void this.maybeGenerateAgentTitle(event.agentId);
|
||||
}
|
||||
// Title updates are now handled by set_title MCP tool calls.
|
||||
},
|
||||
{ replayState: false }
|
||||
);
|
||||
@@ -646,14 +635,9 @@ export class Session {
|
||||
}
|
||||
|
||||
private async getStoredAgentTitle(agentId: string): Promise<string | null> {
|
||||
if (this.agentTitleCache.has(agentId)) {
|
||||
return this.agentTitleCache.get(agentId) ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
const title = record?.title ?? null;
|
||||
this.agentTitleCache.set(agentId, title);
|
||||
return title;
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
@@ -664,59 +648,6 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private setCachedTitle(agentId: string, title: string | null): void {
|
||||
this.agentTitleCache.set(agentId, title);
|
||||
}
|
||||
|
||||
private async maybeGenerateAgentTitle(agentId: string): Promise<void> {
|
||||
if (!isTitleGeneratorInitialized()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingTitle = await this.getStoredAgentTitle(agentId);
|
||||
if (existingTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ACTIVE_TITLE_GENERATIONS.has(agentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeline = this.agentManager.getTimeline(agentId);
|
||||
if (timeline.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = this.agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
return;
|
||||
}
|
||||
|
||||
ACTIVE_TITLE_GENERATIONS.add(agentId);
|
||||
try {
|
||||
this.sessionLogger.debug(
|
||||
{ agentId },
|
||||
`Generating title for agent ${agentId}`
|
||||
);
|
||||
const title = await generateAgentTitle(
|
||||
this.sessionLogger.child({ module: "agent-title-generator" }),
|
||||
timeline,
|
||||
snapshot.cwd
|
||||
);
|
||||
await this.agentRegistry.setTitle(agentId, title);
|
||||
this.setCachedTitle(agentId, title);
|
||||
const latest = this.agentManager.getAgent(agentId) ?? snapshot;
|
||||
await this.forwardAgentState(latest);
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{ err: error, agentId },
|
||||
`Failed to generate title for agent ${agentId}`
|
||||
);
|
||||
} finally {
|
||||
ACTIVE_TITLE_GENERATIONS.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for processing session messages
|
||||
*/
|
||||
@@ -1071,7 +1002,6 @@ export class Session {
|
||||
);
|
||||
}
|
||||
|
||||
this.agentTitleCache.delete(agentId);
|
||||
this.emit({
|
||||
type: "agent_deleted",
|
||||
payload: {
|
||||
@@ -1327,7 +1257,6 @@ export class Session {
|
||||
worktreeName
|
||||
);
|
||||
const snapshot = await this.agentManager.createAgent(sessionConfig);
|
||||
this.setCachedTitle(snapshot.id, null);
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
@@ -1424,7 +1353,6 @@ export class Session {
|
||||
handle,
|
||||
overrides
|
||||
);
|
||||
this.setCachedTitle(snapshot.id, null);
|
||||
await this.agentManager.primeAgentHistory(snapshot.id);
|
||||
await this.forwardAgentState(snapshot);
|
||||
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
|
||||
@@ -1493,7 +1421,6 @@ export class Session {
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
);
|
||||
this.setCachedTitle(agentId, null);
|
||||
}
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { generateObject } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
import type { AgentTimelineItem } from "../server/agent/agent-sdk-types.js";
|
||||
import { curateAgentActivity } from "../server/agent/activity-curator.js";
|
||||
import type pino from "pino";
|
||||
|
||||
let openai: ReturnType<typeof createOpenAI> | null = null;
|
||||
|
||||
export function initializeTitleGenerator(logger: pino.Logger, apiKey: string): void {
|
||||
openai = createOpenAI({ apiKey });
|
||||
logger.child({ action: "initialize" }).info("Agent title generator initialized");
|
||||
}
|
||||
|
||||
export function isTitleGeneratorInitialized(): boolean {
|
||||
return openai !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a concise title for an agent based on its activity
|
||||
* Returns a 3-5 word title similar to ChatGPT/Claude.ai
|
||||
*/
|
||||
export async function generateAgentTitle(
|
||||
logger: pino.Logger,
|
||||
timeline: AgentTimelineItem[],
|
||||
cwd: string
|
||||
): Promise<string> {
|
||||
const titleLogger = logger.child({ action: "generate" });
|
||||
if (!openai) {
|
||||
throw new Error("Title generator not initialized");
|
||||
}
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return "New Agent";
|
||||
}
|
||||
|
||||
const activityContext = curateAgentActivity(timeline);
|
||||
|
||||
if (!activityContext.trim() || activityContext === "No activity to display.") {
|
||||
return "New Agent";
|
||||
}
|
||||
|
||||
try {
|
||||
const titleSchema = z.object({
|
||||
title: z.string().describe("A concise 3-5 word title describing what the agent is working on"),
|
||||
});
|
||||
|
||||
const model = openai("gpt-4o-mini");
|
||||
const prompt = `Generate a concise title for this agent session. The title should describe what the user asked the agent to do.
|
||||
|
||||
IMPORTANT: Focus primarily on [User] messages to understand the task. User messages contain the actual request - use their words and intent to name the chat. Tool calls and assistant messages are just implementation details and should NOT drive the title.
|
||||
|
||||
Be specific but brief (3-5 words). Examples: "Fix Authentication Bug", "Build Dashboard Component", "Refactor API Routes".
|
||||
|
||||
Working directory: ${cwd}
|
||||
|
||||
Activity:
|
||||
${activityContext}`;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (generateObject as any)({
|
||||
model,
|
||||
schema: titleSchema,
|
||||
prompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
const object = result.object as z.infer<typeof titleSchema>;
|
||||
|
||||
titleLogger.debug({ title: object.title }, "Generated agent title");
|
||||
|
||||
return object.title;
|
||||
} catch (err) {
|
||||
titleLogger.error({ err }, "Failed to generate agent title");
|
||||
return "New Agent";
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||
|
||||
export class NotGitRepoError extends Error {
|
||||
readonly cwd: string;
|
||||
readonly code = "NOT_GIT_REPO";
|
||||
|
||||
constructor(cwd: string) {
|
||||
super(`Not a git repository: ${cwd}`);
|
||||
@@ -96,6 +97,25 @@ async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
return branch.length > 0 ? branch : null;
|
||||
}
|
||||
|
||||
export async function renameCurrentBranch(
|
||||
cwd: string,
|
||||
newName: string
|
||||
): Promise<{ previousBranch: string | null; currentBranch: string | null }> {
|
||||
await requireRepoInfo(cwd);
|
||||
|
||||
const previousBranch = await getCurrentBranch(cwd);
|
||||
if (!previousBranch || previousBranch === "HEAD") {
|
||||
throw new Error("Cannot rename branch in detached HEAD state");
|
||||
}
|
||||
|
||||
await execAsync(`git branch -m "${newName}"`, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
const currentBranch = await getCurrentBranch(cwd);
|
||||
return { previousBranch, currentBranch };
|
||||
}
|
||||
|
||||
async function isWorkingTreeDirty(cwd: string, repoType: "bare" | "normal"): Promise<boolean> {
|
||||
if (repoType === "bare") {
|
||||
return false;
|
||||
|
||||
@@ -35,6 +35,13 @@ export interface PaseoWorktreeInfo {
|
||||
head?: string;
|
||||
}
|
||||
|
||||
export type PaseoWorktreeOwnership = {
|
||||
allowed: boolean;
|
||||
repoRoot?: string;
|
||||
worktreeRoot?: string;
|
||||
worktreePath?: string;
|
||||
};
|
||||
|
||||
interface CreateWorktreeOptions {
|
||||
branchName: string;
|
||||
cwd: string;
|
||||
@@ -99,26 +106,26 @@ export async function detectRepoInfo(cwd: string): Promise<RepoInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in a normal git repository
|
||||
const gitDirPath = join(cwd, ".git");
|
||||
if (existsSync(gitDirPath)) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse --show-toplevel", {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const repoRoot = stdout.trim();
|
||||
return {
|
||||
type: "normal",
|
||||
path: repoRoot,
|
||||
name: basename(repoRoot),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error("Failed to determine git repository root");
|
||||
// Fallback: allow running from any subdirectory inside a git checkout/worktree.
|
||||
// Use git's common dir (shared .git) to find the repo root even when cwd has no .git entry.
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
"git rev-parse --path-format=absolute --git-common-dir",
|
||||
{ cwd, env: READ_ONLY_GIT_ENV }
|
||||
);
|
||||
const commonDir = stdout.trim();
|
||||
if (!commonDir) {
|
||||
throw new Error("git-common-dir was empty");
|
||||
}
|
||||
const repoRoot = basename(commonDir) === ".git" ? dirname(commonDir) : dirname(commonDir);
|
||||
return {
|
||||
type: "normal",
|
||||
path: repoRoot,
|
||||
name: basename(repoRoot),
|
||||
};
|
||||
} catch {
|
||||
throw new Error("Not in a git repository");
|
||||
}
|
||||
|
||||
throw new Error("Not in a git repository");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +202,36 @@ function getPaseoWorktreesRoot(repoRoot: string): string {
|
||||
return join(repoRoot, ".paseo", "worktrees");
|
||||
}
|
||||
|
||||
export async function isPaseoOwnedWorktreeCwd(
|
||||
cwd: string
|
||||
): Promise<PaseoWorktreeOwnership> {
|
||||
const repoInfo = await detectRepoInfo(cwd);
|
||||
const worktreesRoot = getPaseoWorktreesRoot(repoInfo.path);
|
||||
const resolvedRoot = resolve(worktreesRoot) + sep;
|
||||
const resolvedCwd = resolve(cwd);
|
||||
|
||||
if (!resolvedCwd.startsWith(resolvedRoot)) {
|
||||
return {
|
||||
allowed: false,
|
||||
repoRoot: repoInfo.path,
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: resolvedCwd,
|
||||
};
|
||||
}
|
||||
|
||||
const worktrees = await listPaseoWorktrees({ cwd: repoInfo.path });
|
||||
const allowed = worktrees.some((entry) => {
|
||||
const worktreePath = resolve(entry.path);
|
||||
return resolvedCwd === worktreePath || resolvedCwd.startsWith(worktreePath + sep);
|
||||
});
|
||||
return {
|
||||
allowed,
|
||||
repoRoot: repoInfo.path,
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: resolvedCwd,
|
||||
};
|
||||
}
|
||||
|
||||
function ensurePaseoIgnoredForRepo(repoInfo: RepoInfo): {
|
||||
updated: boolean;
|
||||
skipped: boolean;
|
||||
|
||||
Reference in New Issue
Block a user