Add provider/model selection for schedules and workspace fetch debug logging

- Unify resolveProviderAndModel across CLI and server, supporting
  provider/model syntax (e.g. codex/gpt-5.4) for agent runs and schedules
- Add --provider flag to schedule create CLI and both MCP schedule tools
- Add structured debug logging to workspace fetch hydrate, sidebar
  refresh, and real-time update paths
- Update orchestrate skill references
This commit is contained in:
Mohamed Boudra
2026-04-14 14:42:10 +07:00
parent 202fa3e8f3
commit dd6a396dbf
17 changed files with 1209 additions and 161 deletions
@@ -55,6 +55,7 @@ import {
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
resolveProviderAndModel,
sanitizePermissionRequest,
serializeSnapshotWithMetadata,
startAgentRun,
@@ -81,13 +82,20 @@ export async function createAgentManagementMcpServer(
component: "agent-management-mcp",
});
const waitTracker = new WaitForAgentTracker(logger);
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
type: "new-agent" as const,
config: {
provider: params?.provider ?? ("claude" as AgentProvider),
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
},
});
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
const resolvedProviderModel = resolveProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
type: "new-agent" as const,
config: {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
},
};
};
const server = new McpServer({
name: "paseo-agent-management",
@@ -656,7 +664,9 @@ export async function createAgentManagementMcpServer(
cron: z.string().optional(),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional(),
provider: AgentProviderEnum.optional().describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
),
cwd: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
@@ -478,6 +478,28 @@ describe("MCP parity end-to-end", () => {
}
});
test("create_schedule accepts provider/model syntax", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(topLevelClient, "create_schedule", {
prompt: "say hello",
every: "5m",
name: "Parity provider schedule",
provider: "codex/gpt-5.4",
});
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
provider: "codex",
model: "gpt-5.4",
},
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("inspect_schedule returns details", async () => {
let scheduleId: string | null = null;
try {
@@ -568,6 +590,27 @@ describe("MCP parity end-to-end", () => {
}
});
test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => {
let scheduleId: string | null = null;
try {
const created = await callToolStructured(agentScopedClient, "create_schedule", {
prompt: "say hello",
every: "5m",
provider: "codex/gpt-5.4",
});
scheduleId = created.id as string;
expect(created.target).toMatchObject({
type: "new-agent",
config: {
provider: "codex",
model: "gpt-5.4",
},
});
} finally {
await deleteScheduleIfPresent(scheduleId);
}
});
test("create_schedule target self without callerAgentId throws", async () => {
await expectToolError(
topLevelClient,
+46 -11
View File
@@ -37,6 +37,7 @@ import {
AgentStatusEnum,
ProviderSummarySchema,
parseDurationString,
resolveProviderAndModel,
sanitizePermissionRequest,
setupFinishNotification,
serializeSnapshotWithMetadata,
@@ -230,16 +231,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return expandUserPath(trimmedCwd);
};
const resolveNewAgentScheduleTarget = () => {
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
const callerAgent = resolveCallerAgent();
if (callerAgent) {
const hasProviderOverride = params?.provider !== undefined;
const resolvedProviderModel = hasProviderOverride
? resolveProviderAndModel({
provider: params?.provider,
defaultProvider: callerAgent.provider,
})
: null;
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
return {
type: "new-agent" as const,
config: {
provider: callerAgent.provider,
cwd: callerAgent.cwd,
...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
provider: resolvedProvider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
...(callerAgent.currentModeId
? {
modeId: mapModeAcrossProviders(
callerAgent.currentModeId,
callerAgent.provider,
resolvedProvider,
),
}
: {}),
...(resolvedProviderModel?.model
? { model: resolvedProviderModel.model }
: !hasProviderOverride && callerAgent.config.model
? { model: callerAgent.config.model }
: {}),
...(callerAgent.config.thinkingOptionId
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
: {}),
@@ -267,10 +288,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return {
type: "new-agent" as const,
config: {
provider: "claude" as AgentProvider,
cwd: process.cwd(),
},
config: (() => {
const resolvedProviderModel = resolveProviderAndModel({
provider: params?.provider,
defaultProvider: "claude",
});
return {
provider: resolvedProviderModel.provider,
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
};
})(),
};
};
const agentToAgentInputSchema = {
@@ -1181,12 +1209,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
cron: z.string().optional(),
name: z.string().optional(),
target: z.enum(["self", "new-agent"]).optional(),
provider: AgentProviderEnum.optional().describe(
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
),
cwd: z.string().optional(),
maxRuns: z.number().int().positive().optional(),
expiresIn: z.string().optional(),
},
outputSchema: ScheduleSummarySchema.shape,
},
async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
if (!scheduleService) {
throw new Error("Schedule service is not configured");
}
@@ -1202,9 +1234,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
if (!callerAgentId) {
throw new Error("target=self requires a caller agent");
}
if (provider !== undefined || cwd !== undefined) {
throw new Error("provider and cwd can only be used with target=new-agent");
}
return { type: "agent" as const, agentId: callerAgentId };
})()
: resolveNewAgentScheduleTarget();
: resolveNewAgentScheduleTarget({ provider, cwd });
const schedule = await scheduleService.create({
prompt: prompt.trim(),
@@ -7,6 +7,7 @@ import { curateAgentActivity } from "./activity-curator.js";
import type { AgentStorage } from "./agent-storage.js";
import { serializeAgentSnapshot } from "../messages.js";
import { StoredScheduleSchema } from "../schedule/types.js";
import type { AgentProvider } from "./agent-sdk-types.js";
export const AgentProviderEnum = z.string();
@@ -48,6 +49,49 @@ export const AgentModelSchema = z.object({
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
export const AGENT_WAIT_TIMEOUT_MS = 30000;
export interface ResolvedProviderModel {
provider: AgentProvider;
model: string | undefined;
}
export function resolveProviderAndModel(params: {
provider?: string;
model?: string;
defaultProvider: AgentProvider;
}): ResolvedProviderModel {
const providerInput = params.provider?.trim() || params.defaultProvider;
const modelInput = params.model?.trim();
if (params.model !== undefined && !modelInput) {
throw new Error("model cannot be empty");
}
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput as AgentProvider,
model: modelInput,
};
}
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
throw new Error("provider must be <provider> or <provider>/<model>");
}
if (modelInput && modelInput !== modelFromProvider) {
throw new Error(
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
);
}
return {
provider: provider as AgentProvider,
model: modelInput ?? modelFromProvider,
};
}
export type StartAgentRunOptions = {
replaceRunning?: boolean;
};
+72
View File
@@ -335,6 +335,42 @@ type FetchWorkspacesCursor = {
id: string;
};
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
count: number;
projectIds: string[];
statusCounts: Record<string, number>;
workspaces: Array<{
id: string;
projectId: string;
projectDisplayName: string;
name: string;
status: FetchWorkspacesResponseEntry["status"];
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
activityAt: string | null;
}>;
} {
const workspaces = Array.from(entries, (entry) => ({
id: entry.id,
projectId: entry.projectId,
projectDisplayName: entry.projectDisplayName,
name: entry.name,
status: entry.status,
workspaceKind: entry.workspaceKind,
activityAt: entry.activityAt,
}));
const statusCounts = new Map<string, number>();
for (const workspace of workspaces) {
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
}
return {
count: workspaces.length,
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
statusCounts: Object.fromEntries(statusCounts),
workspaces,
};
}
class SessionRequestError extends Error {
constructor(
readonly code: string,
@@ -5730,7 +5766,9 @@ export class Session {
const filter = request.filter;
const sort = this.normalizeFetchWorkspacesSort(request.sort);
let entries = await this.listWorkspaceDescriptors();
const listedCount = entries.length;
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
const filteredCount = entries.length;
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
const cursorToken = request.page?.cursor;
@@ -5749,6 +5787,21 @@ export class Session {
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
: null;
this.sessionLogger.debug(
{
requestId: request.requestId,
filter: request.filter ?? null,
sort,
page: request.page ?? null,
listedCount,
filteredCount,
returnedCount: pagedEntries.length,
hasMore,
nextCursor,
},
"fetch_workspaces_entries_listed",
);
return {
entries: pagedEntries,
pageInfo: {
@@ -6031,6 +6084,16 @@ export class Session {
: null;
try {
this.sessionLogger.debug(
{
requestId: request.requestId,
subscribeRequested: Boolean(request.subscribe),
filter: request.filter ?? null,
sort: request.sort ?? null,
page: request.page ?? null,
},
"fetch_workspaces_request_received",
);
if (subscriptionId) {
this.workspaceUpdatesSubscription = {
subscriptionId,
@@ -6042,6 +6105,15 @@ export class Session {
}
const payload = await this.listFetchWorkspacesEntries(request);
this.sessionLogger.debug(
{
requestId: request.requestId,
subscriptionId,
pageInfo: payload.pageInfo,
payload: summarizeFetchWorkspacesEntries(payload.entries),
},
"fetch_workspaces_response_ready",
);
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedLatestActivity = entry.activityAt