From 894e558f04913f9f63d06c8ce997eebf7af32516 Mon Sep 17 00:00:00 2001 From: "propr-dev[bot]" <1316198+propr-dev[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:52:37 +0000 Subject: [PATCH 1/6] fix(ai): Resolve issue #1992 - Add Web UI, CLI, playground, observability, and do Implemented by ProPR AI using gpt-5.6-sol model. Implementation completed successfully. --- docs/docs/concepts/glossary.md | 12 + docs/docs/features/agents-and-models.md | 2 + docs/docs/features/propr-cli.md | 6 + docs/docs/features/synthetic-pools.md | 114 ++++++ docs/docs/features/web-ui.md | 2 + docs/sidebars.ts | 1 + packages/api/routes/agentRoutes.ts | 53 ++- packages/api/routes/statusRoutes.ts | 49 ++- packages/api/test/statusRoutes.test.ts | 44 +++ packages/cli/src/api/index.ts | 12 + packages/cli/src/api/syntheticPools.test.ts | 53 +++ packages/cli/src/api/syntheticPools.ts | 53 +++ packages/cli/src/commands/agentCommands.ts | 3 + .../src/commands/agentPoolCommands.test.ts | 103 ++++++ .../cli/src/commands/agentPoolCommands.ts | 113 ++++++ packages/cli/src/index.ts | 11 +- propr-ui/src/api/agentChatApi.ts | 8 + propr-ui/src/api/configApi.ts | 18 + propr-ui/src/api/proprApi.ts | 2 +- .../src/components/AgentChat/ChatPanel.tsx | 59 ++- .../src/components/GlobalHeaderComponents.tsx | 10 +- .../ModelContextSelector.test.tsx | 36 ++ .../Repositories/ModelContextSelector.tsx | 37 +- .../Repositories/RepoActionContainer.tsx | 11 + .../components/Repositories/RepoChatPanel.tsx | 6 +- .../Repositories/RepoImprovementsPanel.tsx | 2 + .../RepoImprovementsPanel.types.ts | 2 + propr-ui/src/components/SystemStatus.tsx | 7 +- .../components/TaskDetails/ContextStrip.tsx | 14 +- .../components/TaskDetails/LeftPaneBody.tsx | 18 + .../TaskDetails/TaskStatusTable.tsx | 14 + propr-ui/src/components/TaskDetails/index.tsx | 1 + propr-ui/src/components/TaskDetails/types.ts | 11 + .../components/TaskDetails/useHistoryData.ts | 9 +- propr-ui/src/pages/AiAgentsPage.test.tsx | 51 ++- propr-ui/src/pages/AiAgentsPage.tsx | 128 ++++++- propr-ui/src/pages/LlmLogsPage.tsx | 4 +- propr-ui/src/pages/LlmLogsPageComponents.tsx | 45 ++- .../AIModelSelectionSection.test.tsx | 35 ++ .../SettingsPage/AIModelSelectionSection.tsx | 28 +- .../SettingsPage/ReviewContextSettings.tsx | 5 +- propr-ui/src/pages/SettingsPage/index.tsx | 2 + .../SettingsPage/modelSelectionHelpers.ts | 18 +- .../pages/SettingsPage/useSettingsState.ts | 19 +- propr-ui/src/pages/SyntheticPoolsSection.tsx | 338 ++++++++++++++++++ propr-ui/src/utils/agentStatus.ts | 1 + 46 files changed, 1473 insertions(+), 97 deletions(-) create mode 100644 docs/docs/features/synthetic-pools.md create mode 100644 packages/cli/src/api/syntheticPools.test.ts create mode 100644 packages/cli/src/api/syntheticPools.ts create mode 100644 packages/cli/src/commands/agentPoolCommands.test.ts create mode 100644 packages/cli/src/commands/agentPoolCommands.ts create mode 100644 propr-ui/src/components/Repositories/ModelContextSelector.test.tsx create mode 100644 propr-ui/src/pages/SyntheticPoolsSection.tsx diff --git a/docs/docs/concepts/glossary.md b/docs/docs/concepts/glossary.md index 2319c9c3c..2901aafc9 100644 --- a/docs/docs/concepts/glossary.md +++ b/docs/docs/concepts/glossary.md @@ -31,6 +31,18 @@ title: Glossary **Task** — one unit of agent work with its own record: prompt, isolated run, logs, usage, commits, and resulting PR or follow-up. +**Synthetic agent** — a provider-neutral virtual agent whose models route to configured direct agent/model members. See [Synthetic Pools](../features/synthetic-pools.md). + +**Synthetic model** — a virtual model ID exposed by a synthetic agent in normal model selectors. + +**Pool member** — one direct-agent alias and supported physical model participating in a synthetic model. + +**Priority tier** — all eligible pool members at one priority; only the highest currently eligible tier participates in selection. + +**Usage cap** — an optional session or weekly usage percentage above which a capped pool member becomes ineligible. + +**Failover** — retrying the same call and workspace on another eligible pool member after a retryable physical failure. + **Ultrafix** — the automated review-fix loop: `/review` scores the PR, fixes are applied, and cycles repeat until the target score, cycle limit, or a human stop. See [PR Comment Commands](../features/pr-commands.md#ultrafix). **Worktree** — the dedicated Git working directory each task gets, paired with its own branch and container, so parallel tasks never collide and the main checkout stays untouched. diff --git a/docs/docs/features/agents-and-models.md b/docs/docs/features/agents-and-models.md index 055d86cbd..f3b537b05 100644 --- a/docs/docs/features/agents-and-models.md +++ b/docs/docs/features/agents-and-models.md @@ -21,6 +21,8 @@ Use routing when you want to: - Fall back to another provider when rate limits or quota are tight - Preserve the same PR follow-up workflow across providers +For virtual routing across several configured direct agents, see [Synthetic Pools](./synthetic-pools.md). + ## Supported Agents | Agent | Type | Docker image | Existing host credentials | diff --git a/docs/docs/features/propr-cli.md b/docs/docs/features/propr-cli.md index 0021418fb..506bb24be 100644 --- a/docs/docs/features/propr-cli.md +++ b/docs/docs/features/propr-cli.md @@ -268,12 +268,18 @@ propr agent add --file agent-config.json # From a JSON file (or `-` for stdi propr agent enable my-agent # Enable / disable without deleting propr agent disable my-agent propr agent delete my-agent --force + +propr agent pool list --json > pools.json +propr agent pool apply pools.json # Also accepts '-' for stdin +propr agent pool delete balanced-pool ``` Agent types: `claude`, `codex`, `antigravity`, `opencode`, `vibe`. See [Agents and Models](./agents-and-models.md) for the model catalog, label formats, and per-agent credential setup, including the OpenCode host-authentication steps and the `XDG_DATA_HOME` requirement for file-based OpenCode auth. +Synthetic pool commands replace one complete, nested configuration document. JSON from `pool list --json` can be passed unchanged to `pool apply`; validation failures retain the backend's nested field message. See [Synthetic Pools](./synthetic-pools.md) for schemas and routing behavior. + ## To-Dos ```bash diff --git a/docs/docs/features/synthetic-pools.md b/docs/docs/features/synthetic-pools.md new file mode 100644 index 000000000..c0284b776 --- /dev/null +++ b/docs/docs/features/synthetic-pools.md @@ -0,0 +1,114 @@ +--- +title: Synthetic Pools +--- + +# Synthetic Pools + +Synthetic pools give a stable virtual agent/model identity to a set of existing direct agent accounts. They are useful for rotating between two accounts from one provider, balancing capacity, or failing over to a different provider without changing repository, planner, review, or issue configuration. + +## Concepts + +- A **synthetic agent** is a virtual coding agent. It has an alias and one or more synthetic models but no provider credentials of its own. +- A **synthetic model** is a virtual model ID exposed in ProPR's instance catalog and model selectors. +- A **pool member** is one direct-agent alias and one physical model supported by that direct agent. A synthetic agent can never be a member of another pool. +- A **priority tier** is the set of currently eligible members with the same priority, from 0 through 100. Routing considers only the highest eligible tier. +- A **usage cap** makes a member ineligible when its current session or weekly usage reaches a configured percentage. +- **Failover** retries a synthetic call on another eligible member after a retryable physical failure. + +Synthetic choices use a neutral layers icon in the UI because the pool is not owned by a provider. Task lists keep their model column concise by showing the virtual model. Playground results, task details, task-history attempts, and LLM logs also show the physical agent/model that actually ran. + +## Configure in the Web UI + +Installation administrators can open **Coding Agents → Synthetic Pools** to create, edit, enable, disable, or delete pools. Each virtual model supports **Round robin** or **Usage based** routing, an enabled state, and one or more direct members. Each member has an enabled state, priority, and optional session and weekly maximum percentages. + +The member picker contains only configured direct agents and their supported physical models. Disabled direct agents remain visible for correcting existing configuration but are not eligible at runtime. Demo mode is read-only and disables every mutation. + +Backend validation is authoritative. A rejected save keeps the editor and unsaved values open and associates a validation message with its nested model/member field when the response contains a field path. + +### Same-provider round robin + +Create two direct Codex agents, such as `codex-account-a` and `codex-account-b`, using separate credential directories. Add both with the same physical model to one enabled virtual model, give both priority 100, and choose **Round robin**. Successful calls rotate between the two accounts using a cursor shared by the workers. + +### Usage-based selection + +**Usage based** still honors strict priority first. Within the highest eligible tier it selects the member with the most normalized headroom below its configured caps. If no caps are configured, all members have equal headroom; use round robin when deterministic rotation is the goal. + +## Primary and fallback recipe + +For cross-provider primary/fallback routing: + +1. Add the primary member at priority 100. +2. Optionally set its weekly maximum to 80%. +3. Add the fallback member at priority 0. +4. Use either strategy; strategy only chooses among members inside the selected priority tier. + +The priority-0 member is not mixed into normal traffic. It becomes eligible for selection only when every higher-priority member is disabled, capped, unavailable, too small for the call's context, or has failed during that call. This priority-100 primary plus priority-0 fallback pattern is the recommended way to reserve fallback capacity. + +## Context-aware early selection + +ProPR can select a route early so planning and task setup retain one stable physical choice. Before the first physical invocation it finalizes the required prompt plus output reserve. If the selected model's context limit is too small, ProPR reselects without counting that member as a failed attempt. + +Every later failover applies the same context requirement. A smaller-context fallback can therefore be skipped even when it is healthy: sending a prompt that cannot fit would only create a misleading provider failure. + +## Usage data and degraded pools + +A capped member requires fresh Agent Tank data whose name exactly matches the direct-agent alias. Missing, refreshing, stale, provider-wide-only, or differently named data makes that capped member ineligible. The default freshness window is five minutes and can be changed with `SYNTHETIC_USAGE_FRESHNESS_MS`. + +Uncapped pools do not require Agent Tank. If no member of a synthetic model is currently eligible, the pool reports **Degraded**. This does not mark its unrelated direct agents unhealthy; direct-agent health remains independent. + +## Failure retries and workspace preservation + +A retryable physical error fails over to the next eligible, not-yet-attempted member. Every physical attempt is recorded as a separate history entry with the virtual identity, physical agent/model, attempt number, and selection reason. These attempts remain part of one task: ProPR does not create extra tasks or extra worktrees. + +Implementation retries reuse the same task workspace and branch, so edits made before a provider failure remain available to the fallback. Explicit user cancellation, security-policy failures, invalid configuration, and prompts that exceed the context limit are not retried on another member. + +## CLI + +The CLI manages the same complete configuration document: + +```bash +propr agent pool list +propr agent pool list --json > pools.json +propr agent pool apply pools.json +cat pools.json | propr agent pool apply - +propr agent pool delete balanced-pool +propr agent pool delete balanced-pool --json +``` + +`pool list --json` emits `{ "synthetic_agents": [...] }`. That file can be passed unchanged to `pool apply`; `apply` also accepts the array itself. Full-document replacement keeps nested multi-model configuration unambiguous and makes review, backup, and automation straightforward. Backend validation messages, including nested field paths, are printed without being rewritten. + +An abbreviated two-tier document looks like this (IDs must be UUIDs): + +```json +{ + "synthetic_agents": [{ + "id": "11111111-1111-4111-8111-111111111111", + "alias": "balanced-pool", + "enabled": true, + "defaultModel": "balanced", + "models": [{ + "id": "balanced", + "displayName": "Balanced", + "enabled": true, + "strategy": "usage_based", + "members": [ + { + "id": "22222222-2222-4222-8222-222222222222", + "directAgentAlias": "codex-primary", + "model": "gpt-5.6-sol", + "enabled": true, + "priority": 100, + "usageLimits": { "weeklyMaxPercent": 80 } + }, + { + "id": "33333333-3333-4333-8333-333333333333", + "directAgentAlias": "claude-fallback", + "model": "claude-sonnet-5", + "enabled": true, + "priority": 0 + } + ] + }] + }] +} +``` diff --git a/docs/docs/features/web-ui.md b/docs/docs/features/web-ui.md index 3fc977845..831a1333b 100644 --- a/docs/docs/features/web-ui.md +++ b/docs/docs/features/web-ui.md @@ -58,6 +58,8 @@ See [Repository Knowledge](./repository-knowledge.md) and [Branch Configuration] **Coding Agents** (`/ai-agents`) is an administrator-only split view: configure agent aliases and their models on one side, and a **playground** to test an agent interactively on the other. When adding Claude, Codex, Antigravity, or OpenCode, choose a new-account login or reuse an existing config. New-account login creates an isolated ProPR-managed credential directory, so multiple accounts of the same provider can coexist without entering host paths. The login dialog starts the configured agent image, displays the CLI's authorization link and instructions, and accepts requested confirmation codes or terminal menu input without requiring the agent CLI on the host. Existing entries also include **Log in**. The dialog includes Up, Down, and Enter controls for provider and login-method menus; Escape or backdrop dismissal cancels its temporary container. Vibe uses an API key or pre-populated config instead of this interactive flow. See [Agents And Models](./agents-and-models.md). +Administrators can switch the configuration pane to **Synthetic Pools** to combine direct agent/model pairs behind virtual models with strict priority tiers, usage caps, round-robin or usage-based routing, and failover. Synthetic models also appear in the playground, which reports the virtual choice and physical member used. See [Synthetic Pools](./synthetic-pools.md). + ## LLM Log **LLM Log** (`/llm-logs`) shows every model call with expandable rows and filters by execution type, model, status, and work type. What each record contains and how to use the page for cost analysis is covered in [Metrics](../operations/metrics.md). diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 00ebb2b77..b8bde75e4 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -82,6 +82,7 @@ const sidebars: SidebarsConfig = { label: 'Reference', items: [ 'features/agents-and-models', + 'features/synthetic-pools', 'features/propr-cli', ], }, diff --git a/packages/api/routes/agentRoutes.ts b/packages/api/routes/agentRoutes.ts index 394d1463a..bab9062bb 100644 --- a/packages/api/routes/agentRoutes.ts +++ b/packages/api/routes/agentRoutes.ts @@ -11,6 +11,7 @@ import { toProprOpenCodeModelId, type Agent, type AgentRegistry, + SyntheticAgent, } from '@propr/core'; import { AGENT_DEFAULTS, isManagedAgentConfigPath } from '@propr/shared'; import { requireManageAgents } from '../permissionGuards.js'; @@ -19,6 +20,7 @@ const execFileAsync = promisify(execFile); interface AgentChatQuery { agentId: string; + syntheticConfigId?: string; model?: string; } @@ -35,6 +37,31 @@ interface AgentChatResult { response?: string; error?: string; durationMs: number; + syntheticConfigId?: string; + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; +} + +interface ChatRoutingMetadata { + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; +} + +function chatRoutingFields(metadata: Record | undefined): ChatRoutingMetadata { + if (!metadata) return {}; + return { + virtualAgentAlias: typeof metadata.virtualAgentAlias === 'string' ? metadata.virtualAgentAlias : undefined, + virtualModel: typeof metadata.virtualModel === 'string' ? metadata.virtualModel : undefined, + physicalAgentAlias: typeof metadata.physicalAgentAlias === 'string' ? metadata.physicalAgentAlias : undefined, + physicalModel: typeof metadata.physicalModel === 'string' ? metadata.physicalModel : undefined, + attemptNumber: typeof metadata.attemptNumber === 'number' ? metadata.attemptNumber : undefined, + }; } function resolveHostPath(configPath: string): string { @@ -171,11 +198,12 @@ export function createAgentRoutes() { // use the same agent credentials concurrently. const results: AgentChatResult[] = []; for (const query of queries) { - const agent = await resolveChatAgent(registry, query.agentId); + const requestedAgentId = query.syntheticConfigId || query.agentId; + const agent = await resolveChatAgent(registry, requestedAgentId); if (!agent) { results.push({ - agentId: query.agentId, + agentId: requestedAgentId, model: query.model || 'default', error: 'Agent not found', durationMs: 0 @@ -184,21 +212,32 @@ export function createAgentRoutes() { } const start = Date.now(); + const routingSession = agent instanceof SyntheticAgent + ? agent.beginRoutingSession(query.model) + : undefined; try { - const analysisResult = await agent.analyze(prompt, { context, model: query.model }); + const analysisResult = routingSession + ? await routingSession.analyze(prompt, { context, model: query.model }) + : await agent.analyze(prompt, { context, model: query.model }); + const routing = chatRoutingFields(routingSession?.routingMetadata); results.push({ - agentId: query.agentId, + agentId: requestedAgentId, + ...(query.syntheticConfigId ? { syntheticConfigId: query.syntheticConfigId } : {}), agentAlias: agent.config.alias, - model: canonicalChatModel(agent, analysisResult.modelUsed || query.model), + model: routing.virtualModel || canonicalChatModel(agent, analysisResult.modelUsed || query.model), + ...routing, response: analysisResult.response, error: analysisResult.success === false ? (analysisResult.error || 'Analysis failed') : undefined, durationMs: Date.now() - start }); } catch (err) { + const routing = chatRoutingFields(routingSession?.routingMetadata); results.push({ - agentId: query.agentId, + agentId: requestedAgentId, + ...(query.syntheticConfigId ? { syntheticConfigId: query.syntheticConfigId } : {}), agentAlias: agent.config.alias, - model: canonicalChatModel(agent, query.model), + model: routing.virtualModel || canonicalChatModel(agent, query.model), + ...routing, error: (err as Error).message, durationMs: Date.now() - start }); diff --git a/packages/api/routes/statusRoutes.ts b/packages/api/routes/statusRoutes.ts index 5439fac52..5cb5532ae 100644 --- a/packages/api/routes/statusRoutes.ts +++ b/packages/api/routes/statusRoutes.ts @@ -14,9 +14,11 @@ import { AgentRegistry, getIndexingQueue as loadIndexingQueue, loadAgents as loadAgentConfigs, + loadSyntheticAgents as loadSyntheticAgentConfigs, loadSummarizationRuntimeState } from '@propr/core'; import type { Agent, AgentConfig, AgentRegistryOperationalStatus } from '@propr/core'; +import type { SyntheticAgentConfig } from '@propr/shared'; import path from 'node:path'; import os from 'node:os'; import { applyRoutingStatus, parseConnectAccountStatus, type RoutingState } from './connectAccountStatus.js'; @@ -25,6 +27,7 @@ interface StatusRoutesDeps { redisClient: RedisClientType; agentRegistry?: StatusAgentRegistry; loadAgents?: () => Promise; + loadSyntheticAgents?: () => Promise; getIndexingQueue?: () => Promise; agentStatusCacheTtlMs?: number; agentHealthTimeoutMs?: number; @@ -49,9 +52,9 @@ type ServiceStatus = 'connected' | 'disconnected' | 'active' | 'queued' | 'idle' interface AgentStatus { id: string; - type: AgentConfig['type']; + type: AgentConfig['type'] | 'synthetic'; alias: string; - status: 'connected' | 'disconnected'; + status: 'connected' | 'disconnected' | 'degraded'; } export function createStatusRoutes(deps: StatusRoutesDeps) { @@ -59,6 +62,7 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { redisClient, agentRegistry = AgentRegistry.getInstance() as StatusAgentRegistry, loadAgents = loadAgentConfigs, + loadSyntheticAgents: configuredSyntheticLoader, getIndexingQueue = loadIndexingQueue, agentStatusCacheTtlMs = 5000, agentHealthTimeoutMs = 1500, @@ -66,6 +70,11 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { loadSummarizationRuntimeState: loadSummarizationRuntimeStateDep = loadSummarizationRuntimeState, projectSystemSnapshot } = deps; + // Unit/integration callers that replace the direct config loader predate + // synthetic pools. Treat that fixture as an empty synthetic document unless + // it explicitly supplies one; production still uses persisted configuration. + const loadSyntheticAgents = configuredSyntheticLoader + ?? (deps.loadAgents ? async () => [] : loadSyntheticAgentConfigs); let agentStatusCache: { expiresAt: number; statuses: AgentStatus[] } | undefined; function getCompatibility(_req: Request, res: Response): void { @@ -203,7 +212,7 @@ export function createStatusRoutes(deps: StatusRoutesDeps) { return agentStatusCache.statuses; } - const statuses = await getAgentStatuses(loadAgents, agentRegistry, agentHealthTimeoutMs); + const statuses = await getAgentStatuses(loadAgents, loadSyntheticAgents, agentRegistry, agentHealthTimeoutMs); agentStatusCache = { statuses, expiresAt: currentTime + agentStatusCacheTtlMs @@ -363,16 +372,25 @@ function formatCooldownUntil(until: string): string { async function getAgentStatuses( loadAgents: () => Promise, + loadSyntheticAgents: () => Promise, registry: StatusAgentRegistry, healthTimeoutMs: number ): Promise { let configuredAgents: AgentConfig[]; + let syntheticAgents: SyntheticAgentConfig[] = []; try { configuredAgents = await loadAgents(); } catch (error) { console.error('Error loading agent status configuration:', error); return []; } + try { + syntheticAgents = await loadSyntheticAgents(); + } catch (error) { + // Synthetic configuration availability must not suppress or downgrade + // unrelated direct-agent health. + console.error('Error loading synthetic agent status configuration:', error); + } try { await registry.ensureInitialized(); @@ -380,7 +398,7 @@ async function getAgentStatuses( console.error('Error initializing agent registry for status:', error); } - if (configuredAgents.length === 0) { + if (configuredAgents.length === 0 && syntheticAgents.length === 0) { const defaultAgent = registry.getAgentById('default-claude-agent') ?? registry.getAgentByAlias('default'); if (defaultAgent?.config.type === 'claude') { return [await buildRegisteredAgentStatus(defaultAgent, healthTimeoutMs)]; @@ -391,7 +409,7 @@ async function getAgentStatuses( const registeredById = new Map(registry.getAllAgents().map(agent => [agent.config.id, agent])); const registeredByAlias = new Map(registry.getAllAgents().map(agent => [agent.config.alias, agent])); - return Promise.all(configuredAgents + const directStatuses = await Promise.all(configuredAgents .filter(agent => agent.enabled) .map(async (config) => { const registeredAgent = registeredById.get(config.id) ?? registeredByAlias.get(config.alias); @@ -400,6 +418,27 @@ async function getAgentStatuses( } return buildRegisteredAgentStatus(registeredAgent, healthTimeoutMs); })); + + const syntheticStatuses = await Promise.all(syntheticAgents + .filter(pool => pool.enabled) + .map(async pool => { + const registered = registeredById.get(pool.id) ?? registeredByAlias.get(pool.alias); + if (!registered) return { id: pool.id, type: 'synthetic' as const, alias: pool.alias, status: 'degraded' as const }; + let healthy = false; + try { + healthy = await withTimeout(registered.healthCheck(), healthTimeoutMs, false); + } catch { + healthy = false; + } + return { + id: pool.id, + type: 'synthetic' as const, + alias: pool.alias, + status: healthy ? 'connected' as const : 'degraded' as const, + }; + })); + + return [...directStatuses, ...syntheticStatuses]; } function getDefaultClaudeConfig(): AgentConfig { diff --git a/packages/api/test/statusRoutes.test.ts b/packages/api/test/statusRoutes.test.ts index 7725c22e4..1675b1597 100644 --- a/packages/api/test/statusRoutes.test.ts +++ b/packages/api/test/statusRoutes.test.ts @@ -5,11 +5,13 @@ import type { Request, Response as ExpressResponse } from 'express'; import type { Agent, AgentConfig } from '@propr/core'; import type { RedisClientType } from 'redis'; import { PROPR_API_COMPATIBILITY, PROPR_UI_COMPATIBILITY, PROPR_VERSION } from '@propr/shared'; +import type { SyntheticAgentConfig } from '@propr/shared'; type StatusRoutesDeps = { redisClient: RedisClientType; agentRegistry?: StatusAgentRegistry; loadAgents?: () => Promise; + loadSyntheticAgents?: () => Promise; getIndexingQueue?: () => Promise<{ getJobCounts: (...statuses: string[]) => Promise> }>; agentStatusCacheTtlMs?: number; agentHealthTimeoutMs?: number; @@ -313,6 +315,48 @@ test('/api/status caches agent health checks briefly', async () => { assert.deepEqual(first.body().agents, second.body().agents); }); +test('/api/status marks an unavailable synthetic pool degraded without downgrading direct agents', async () => { + const direct = createAgentConfig(); + const syntheticConfig: SyntheticAgentConfig = { + id: '11111111-1111-4111-8111-111111111111', + alias: 'balanced-pool', + enabled: true, + defaultModel: 'balanced', + models: [{ + id: 'balanced', + enabled: true, + strategy: 'round_robin', + members: [{ + id: '22222222-2222-4222-8222-222222222222', + directAgentAlias: direct.alias, + model: direct.supportedModels[0], + enabled: true, + priority: 100, + }], + }], + }; + const syntheticFacade = createAgent({ + ...direct, + id: syntheticConfig.id, + alias: syntheticConfig.alias, + supportedModels: ['balanced'], + defaultModel: 'balanced', + }, async () => false); + const body = await readStatus({ + loadAgents: async () => [direct], + loadSyntheticAgents: async () => [syntheticConfig], + agentRegistry: createRegistry([ + createAgent(direct, async () => true), + syntheticFacade, + ]), + }); + + assert.deepEqual(body.agents, [ + { id: direct.id, type: direct.type, alias: direct.alias, status: 'connected' }, + { id: syntheticConfig.id, type: 'synthetic', alias: syntheticConfig.alias, status: 'degraded' }, + ]); +}); + test('/api/status reports resolved auth mode and event intake mode', async () => { const body = await readStatus(); diff --git a/packages/cli/src/api/index.ts b/packages/cli/src/api/index.ts index 0ee26f281..050dd105e 100644 --- a/packages/cli/src/api/index.ts +++ b/packages/cli/src/api/index.ts @@ -157,6 +157,18 @@ export type { SaveAgentsResponse, } from "./agents.js"; +// Synthetic agent pools configuration API +export { + listSyntheticAgents, + saveSyntheticAgents, + deleteSyntheticAgent, +} from "./syntheticPools.js"; + +export type { + SyntheticAgentsResponse, + SaveSyntheticAgentsResponse, +} from "./syntheticPools.js"; + // System Settings API export { getSettings, diff --git a/packages/cli/src/api/syntheticPools.test.ts b/packages/cli/src/api/syntheticPools.test.ts new file mode 100644 index 000000000..0897a4e0c --- /dev/null +++ b/packages/cli/src/api/syntheticPools.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { SyntheticAgentConfig } from "@propr/shared"; +import type { ApiClient } from "./client.js"; +import { + deleteSyntheticAgent, + listSyntheticAgents, + saveSyntheticAgents, +} from "./syntheticPools.js"; + +const pool: SyntheticAgentConfig = { + id: "11111111-1111-4111-8111-111111111111", + alias: "pool", + enabled: true, + defaultModel: "virtual", + models: [{ + id: "virtual", + enabled: true, + strategy: "round_robin", + members: [{ + id: "22222222-2222-4222-8222-222222222222", + directAgentAlias: "codex-a", + model: "gpt-5.6-sol", + enabled: true, + priority: 100, + }], + }], +}; + +test("synthetic pool helpers use the complete configuration endpoint", async () => { + const calls: Array<{ method: string; endpoint: string; options?: unknown }> = []; + const client = { + async get(endpoint: string) { + calls.push({ method: "GET", endpoint }); + return { data: { synthetic_agents: [pool] }, status: 200, headers: new Headers() }; + }, + async post(endpoint: string, options?: unknown) { + calls.push({ method: "POST", endpoint, options }); + return { data: { success: true, synthetic_agents: [] }, status: 200, headers: new Headers() }; + }, + } as unknown as ApiClient; + + assert.deepEqual(await listSyntheticAgents(client), { synthetic_agents: [pool] }); + await saveSyntheticAgents([pool], client); + await deleteSyntheticAgent("pool", client); + + assert.deepEqual(calls, [ + { method: "GET", endpoint: "/api/config/synthetic-agents" }, + { method: "POST", endpoint: "/api/config/synthetic-agents", options: { body: { synthetic_agents: [pool] } } }, + { method: "GET", endpoint: "/api/config/synthetic-agents" }, + { method: "POST", endpoint: "/api/config/synthetic-agents", options: { body: { synthetic_agents: [] } } }, + ]); +}); diff --git a/packages/cli/src/api/syntheticPools.ts b/packages/cli/src/api/syntheticPools.ts new file mode 100644 index 000000000..71bc82725 --- /dev/null +++ b/packages/cli/src/api/syntheticPools.ts @@ -0,0 +1,53 @@ +/** Typed helpers for the synthetic-agent configuration endpoint. */ + +import type { SyntheticAgentConfig } from "@propr/shared"; +import { ApiClient, createApiClient } from "./client.js"; + +export interface SyntheticAgentsResponse { + synthetic_agents: SyntheticAgentConfig[]; +} + +export interface SaveSyntheticAgentsResponse extends SyntheticAgentsResponse { + success: boolean; + warnings?: string[]; + committed?: boolean; +} + +/** Lists the complete synthetic configuration document. */ +export async function listSyntheticAgents( + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + return (await apiClient.get( + "/api/config/synthetic-agents" + )).data; +} + +/** Replaces the complete synthetic configuration document. */ +export async function saveSyntheticAgents( + syntheticAgents: SyntheticAgentConfig[], + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + return (await apiClient.post( + "/api/config/synthetic-agents", + { body: { synthetic_agents: syntheticAgents } } + )).data; +} + +/** Deletes one synthetic agent by its stable ID or alias. */ +export async function deleteSyntheticAgent( + idOrAlias: string, + client?: ApiClient +): Promise { + const apiClient = client ?? (await createApiClient()); + const current = await listSyntheticAgents(apiClient); + const match = current.synthetic_agents.find( + (pool) => pool.id === idOrAlias || pool.alias === idOrAlias + ); + if (!match) throw new Error(`Synthetic pool '${idOrAlias}' not found`); + return saveSyntheticAgents( + current.synthetic_agents.filter((pool) => pool.id !== match.id), + apiClient + ); +} diff --git a/packages/cli/src/commands/agentCommands.ts b/packages/cli/src/commands/agentCommands.ts index ee177aaa0..d928e5ffd 100644 --- a/packages/cli/src/commands/agentCommands.ts +++ b/packages/cli/src/commands/agentCommands.ts @@ -30,6 +30,7 @@ import { JsonInputError, } from "../utils/index.js"; import { presentApiError } from "../utils/apiErrorPresentation.js"; +import { createAgentPoolCommand } from "./agentPoolCommands.js"; const AGENT_TYPE_LIST = AGENT_TYPES.join(", "); @@ -140,6 +141,8 @@ Examples: $ propr agent delete my-agent # Delete an agent `); + agent.addCommand(createAgentPoolCommand()); + // agent list agent .command("list") diff --git a/packages/cli/src/commands/agentPoolCommands.test.ts b/packages/cli/src/commands/agentPoolCommands.test.ts new file mode 100644 index 000000000..d6693e910 --- /dev/null +++ b/packages/cli/src/commands/agentPoolCommands.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentCommand } from "./agentCommands.js"; + +const originalFetch = globalThis.fetch; +const originalLog = console.log; +const originalError = console.error; +const originalHome = process.env.HOME; +const originalExitCode = process.exitCode; + +afterEach(() => { + globalThis.fetch = originalFetch; + console.log = originalLog; + console.error = originalError; + process.exitCode = originalExitCode; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +const document = { + synthetic_agents: [{ + id: "11111111-1111-4111-8111-111111111111", + alias: "balanced-pool", + enabled: true, + defaultModel: "balanced", + models: [{ + id: "balanced", + enabled: true, + strategy: "round_robin", + members: [{ + id: "22222222-2222-4222-8222-222222222222", + directAgentAlias: "codex-a", + model: "gpt-5.6-sol", + enabled: true, + priority: 100, + }], + }], + }], +}; + +test("pool list JSON can be passed unchanged to pool apply", async () => { + const temporaryHome = await mkdtemp(join(tmpdir(), "propr-pool-command-")); + const file = join(temporaryHome, "pools.json"); + const stdout: string[] = []; + const requests: Array<{ method: string; body?: unknown }> = []; + process.env.HOME = temporaryHome; + console.log = (...values: unknown[]) => stdout.push(values.map(String).join(" ")); + console.error = () => undefined; + globalThis.fetch = (async (_input, init) => { + const method = init?.method ?? "GET"; + requests.push({ + method, + ...(typeof init?.body === "string" ? { body: JSON.parse(init.body) } : {}), + }); + const body = method === "GET" ? document : { success: true, ...document }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + await createAgentCommand().parseAsync(["pool", "list", "--json"], { from: "user" }); + assert.equal(stdout.length, 1); + assert.deepEqual(JSON.parse(stdout[0]), document); + await writeFile(file, stdout[0], "utf8"); + + stdout.length = 0; + await createAgentCommand().parseAsync(["pool", "apply", file, "--json"], { from: "user" }); + + assert.deepEqual(requests, [ + { method: "GET" }, + { method: "POST", body: document }, + ]); + assert.deepEqual(JSON.parse(stdout[0]), { success: true, ...document }); + } finally { + await rm(temporaryHome, { recursive: true, force: true }); + } +}); + +test("pool apply preserves backend nested validation messages", async () => { + const temporaryHome = await mkdtemp(join(tmpdir(), "propr-pool-error-")); + const file = join(temporaryHome, "pools.json"); + const stderr: string[] = []; + process.env.HOME = temporaryHome; + await writeFile(file, JSON.stringify(document), "utf8"); + console.log = () => undefined; + console.error = (...values: unknown[]) => stderr.push(values.map(String).join(" ")); + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: "synthetic_agents.0.models.0.members.0.priority: Number must be greater than or equal to 0", + }), { status: 400, headers: { "content-type": "application/json" } })) as typeof fetch; + + try { + await createAgentCommand().parseAsync(["pool", "apply", file], { from: "user" }); + assert.match(stderr.join("\n"), /synthetic_agents\.0\.models\.0\.members\.0\.priority: Number must be greater than or equal to 0/); + assert.equal(process.exitCode, 1); + } finally { + await rm(temporaryHome, { recursive: true, force: true }); + } +}); diff --git a/packages/cli/src/commands/agentPoolCommands.ts b/packages/cli/src/commands/agentPoolCommands.ts new file mode 100644 index 000000000..12a7d3314 --- /dev/null +++ b/packages/cli/src/commands/agentPoolCommands.ts @@ -0,0 +1,113 @@ +import { Command } from "commander"; +import type { SyntheticAgentConfig } from "@propr/shared"; +import { + deleteSyntheticAgent, + listSyntheticAgents, + saveSyntheticAgents, + type SyntheticAgentsResponse, +} from "../api/syntheticPools.js"; +import { NetworkError } from "../api/errors.js"; +import { JsonInputError, printOutput, readJsonInput } from "../utils/io.js"; +import { presentApiError } from "../utils/apiErrorPresentation.js"; + +function poolsFromInput(value: unknown): SyntheticAgentConfig[] { + if (Array.isArray(value)) return value as SyntheticAgentConfig[]; + if (value && typeof value === "object") { + const pools = (value as Partial).synthetic_agents; + if (Array.isArray(pools)) return pools; + } + throw new JsonInputError( + "Input must be a synthetic_agents response from 'pool list --json' or an array of synthetic agents" + ); +} + +function printPoolTable(pools: SyntheticAgentConfig[]): void { + if (pools.length === 0) { + console.log("No synthetic pools configured."); + return; + } + + console.log("Alias Enabled Default model Virtual models"); + console.log("---------------------------------------------------------------------"); + for (const pool of pools) { + const models = pool.models.map((model) => model.id).join(", "); + console.log( + `${pool.alias.padEnd(21)} ${String(pool.enabled ? "Yes" : "No").padEnd(8)} ${pool.defaultModel.padEnd(21)} ${models}` + ); + } +} + +function reportPoolError(error: unknown, action: string): void { + if (error instanceof NetworkError) { + console.error("Error: cannot reach the ProPR backend. Start the stack first: propr start"); + return; + } + if (error instanceof JsonInputError) { + console.error(`Error: ${error.message}`); + return; + } + presentApiError(error, { + forbiddenMessage: "Error: Access denied. You do not have permission to manage synthetic pools.", + // Preserve the backend's nested-field validation message verbatim. + fallbackMessage: (message) => `Error ${action} synthetic pools: ${message}`, + }); +} + +export function createAgentPoolCommand(): Command { + const pool = new Command("pool") + .description("Manage synthetic agent pools") + .addHelpText("after", ` +Examples: + $ propr agent pool list + $ propr agent pool list --json > pools.json + $ propr agent pool apply pools.json + $ cat pools.json | propr agent pool apply - + $ propr agent pool delete balanced-pool +`); + + pool.command("list") + .description("List the complete synthetic pool configuration") + .option("-j, --json", "Output JSON that can be passed unchanged to pool apply") + .action(async (options: { json?: boolean }) => { + try { + const result = await listSyntheticAgents(); + if (printOutput(result, options.json ?? false)) return; + printPoolTable(result.synthetic_agents); + } catch (error) { + reportPoolError(error, "listing"); + process.exitCode = 1; + } + }); + + pool.command("apply ") + .description("Replace synthetic pools from a JSON file, or '-' for stdin") + .option("-j, --json", "Output the backend response as JSON") + .action(async (file: string, options: { json?: boolean }) => { + try { + const pools = poolsFromInput(await readJsonInput(file)); + const result = await saveSyntheticAgents(pools); + if (printOutput(result, options.json ?? false)) return; + console.log(`Applied ${result.synthetic_agents.length} synthetic pool(s).`); + for (const warning of result.warnings ?? []) console.warn(`Warning: ${warning}`); + } catch (error) { + reportPoolError(error, "applying"); + process.exitCode = 1; + } + }); + + pool.command("delete ") + .description("Delete a synthetic pool by ID or alias") + .option("-j, --json", "Output the backend response as JSON") + .action(async (idOrAlias: string, options: { json?: boolean }) => { + try { + const result = await deleteSyntheticAgent(idOrAlias); + if (printOutput(result, options.json ?? false)) return; + console.log(`Deleted synthetic pool '${idOrAlias}'.`); + } catch (error) { + reportPoolError(error, "deleting"); + process.exitCode = 1; + } + }); + + return pool; +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7c85777c2..e88da6066 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -73,6 +73,15 @@ export { TimeoutError, createApiError, } from "./api/index.js"; +export { + listSyntheticAgents, + saveSyntheticAgents, + deleteSyntheticAgent, +} from "./api/index.js"; +export type { + SyntheticAgentsResponse, + SaveSyntheticAgentsResponse, +} from "./api/index.js"; export type { HttpMethod, RequestOptions, @@ -162,7 +171,7 @@ Command Groups: Implementation: issue [implement] Tasks: task [inspect|list|get|stop|delete|followup|import|revert] Repositories: repo [list|add|remove|toggle|index|status] - Agents: agent [list|add|enable|disable|delete] + Agents: agent [list|add|enable|disable|delete|pool] Settings: setting [get|update|reindex-summaries] To-Dos: todo [list|get|add|complete|delete] Logs: log [list] diff --git a/propr-ui/src/api/agentChatApi.ts b/propr-ui/src/api/agentChatApi.ts index 54a523e0b..b4433e92e 100644 --- a/propr-ui/src/api/agentChatApi.ts +++ b/propr-ui/src/api/agentChatApi.ts @@ -3,6 +3,8 @@ import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; export interface ChatQuery { agentId: string; + /** Stable synthetic configuration identity, present only for pool choices. */ + syntheticConfigId?: string; model?: string; } @@ -13,6 +15,12 @@ export interface ChatResult { response?: string; error?: string; durationMs: number; + syntheticConfigId?: string; + virtualAgentAlias?: string; + virtualModel?: string; + physicalAgentAlias?: string; + physicalModel?: string; + attemptNumber?: number; } export const chatWithAgents = async ( diff --git a/propr-ui/src/api/configApi.ts b/propr-ui/src/api/configApi.ts index 34e2ace1c..95e14f20b 100644 --- a/propr-ui/src/api/configApi.ts +++ b/propr-ui/src/api/configApi.ts @@ -5,6 +5,7 @@ import type { RepoConfigResponse, SystemSettings, } from './proprTypes'; +import type { SyntheticAgentConfig } from '@propr/shared'; import { API_BASE_URL, apiFetch, handleApiResponse } from './apiClient'; async function getJson(path: string): Promise { @@ -95,6 +96,23 @@ export interface SaveAgentsResponse { export const getAgents = (): Promise<{ agents: AgentConfig[] }> => getJson('/api/config/agents'); export const saveAgents = (agents: AgentConfig[]): Promise => postJson('/api/config/agents', { agents }); + +export interface SyntheticAgentsResponse { + synthetic_agents: SyntheticAgentConfig[]; +} + +export interface SaveSyntheticAgentsResponse extends SyntheticAgentsResponse { + success: boolean; + warnings?: string[]; +} + +export const getSyntheticAgents = (): Promise => + getJson('/api/config/synthetic-agents'); + +export const saveSyntheticAgents = ( + syntheticAgents: SyntheticAgentConfig[], +): Promise => + postJson('/api/config/synthetic-agents', { synthetic_agents: syntheticAgents }); export const getOpenCodeModels = (agentId?: string): Promise<{ models: string[] }> => { const params = agentId ? `?agentId=${encodeURIComponent(agentId)}` : ''; return getJson(`/api/agents/opencode/models${params}`); diff --git a/propr-ui/src/api/proprApi.ts b/propr-ui/src/api/proprApi.ts index 5927f1688..b0592a913 100644 --- a/propr-ui/src/api/proprApi.ts +++ b/propr-ui/src/api/proprApi.ts @@ -33,7 +33,7 @@ export const getSystemStatus = async (): Promise => { const workers: { id: number; status: string }[] = []; for (let i = 0; i < (data.workerCount || 0); i++) workers.push({ id: i + 1, status: 'active' }); const mapAuthStatus = (status?: string) => status === 'connected' ? 'Authenticated' : 'Failed'; - const mapAgentStatus = (status?: string) => status === 'connected' ? 'Ready' : 'Failed'; + const mapAgentStatus = (status?: string) => status === 'connected' ? 'Ready' : status === 'degraded' ? 'Degraded' : 'Failed'; const mapIndexingStatus = (status?: string) => { switch (status) { case 'active': diff --git a/propr-ui/src/components/AgentChat/ChatPanel.tsx b/propr-ui/src/components/AgentChat/ChatPanel.tsx index a92b23495..bd629805e 100644 --- a/propr-ui/src/components/AgentChat/ChatPanel.tsx +++ b/propr-ui/src/components/AgentChat/ChatPanel.tsx @@ -3,18 +3,24 @@ import { AgentConfig, chatWithAgents, ChatResult, ChatQuery } from '../../api/pr import { MODEL_INFO_MAP, AgentType } from '../../config/modelDefinitions'; import { ProviderLogo } from '../ui/ProviderLogo'; import { Bot, User, Send } from 'lucide-react'; +import { Layers3 } from 'lucide-react'; +import type { SyntheticAgentConfig } from '@propr/shared'; // Enhanced badge colors for selected state - more visually prominent -const selectedBadgeColors: Record = { +type AgentVisualType = AgentType | 'synthetic'; + +const selectedBadgeColors: Record = { claude: 'bg-orange-500 text-white border-orange-600 shadow-md ring-2 ring-orange-300', codex: 'bg-green-500 text-white border-green-600 shadow-md ring-2 ring-green-300', antigravity: 'bg-violet-500 text-white border-violet-600 shadow-md ring-2 ring-violet-300', opencode: 'bg-cyan-500 text-white border-cyan-600 shadow-md ring-2 ring-cyan-300', - vibe: 'bg-pink-500 text-white border-pink-600 shadow-md ring-2 ring-pink-300' + vibe: 'bg-pink-500 text-white border-pink-600 shadow-md ring-2 ring-pink-300', + synthetic: 'bg-slate-600 text-white border-slate-700 shadow-md ring-2 ring-slate-300' }; interface ChatPanelProps { agents: AgentConfig[]; + syntheticAgents?: SyntheticAgentConfig[]; selectedModels: AgentModelSelection[]; onSelectedModelsChange: (selectedModels: AgentModelSelection[]) => void; disabled?: boolean; @@ -36,7 +42,8 @@ interface Message { interface AgentModelOption { agentId: string; agentAlias: string; - agentType: AgentType; + agentType: AgentVisualType; + syntheticConfigId?: string; modelId: string; modelName: string; } @@ -56,6 +63,7 @@ const haveSameSelections = ( const ChatPanel: React.FC = ({ agents, + syntheticAgents = [], selectedModels, onSelectedModelsChange, disabled = false @@ -80,8 +88,20 @@ const ChatPanel: React.FC = ({ }); }); }); + syntheticAgents.filter(pool => pool.enabled).forEach(pool => { + pool.models.filter(model => model.enabled).forEach(model => { + options.push({ + agentId: pool.id, + syntheticConfigId: pool.id, + agentAlias: pool.alias, + agentType: 'synthetic', + modelId: model.id, + modelName: model.displayName || model.id, + }); + }); + }); return options; - }, [agents]); + }, [agents, syntheticAgents]); // Keep selections limited to combinations exposed by the Playground. If an // agent is disabled or removed, fall back to the first available option. @@ -124,10 +144,14 @@ const ChatPanel: React.FC = ({ ).join('\n'); // Build queries with agent+model combinations - const queries: ChatQuery[] = selectedModels.map(selection => ({ - agentId: selection.agentId, - model: selection.modelId - })); + const queries: ChatQuery[] = selectedModels.map(selection => { + const option = agentModelOptions.find(candidate => isSameAgentModel(candidate, selection)); + return { + agentId: selection.agentId, + ...(option?.syntheticConfigId ? { syntheticConfigId: option.syntheticConfigId } : {}), + model: selection.modelId, + }; + }); const { results } = await chatWithAgents(queries, userMsg.content!, context); @@ -209,7 +233,9 @@ const ChatPanel: React.FC = ({ : 'bg-white/70 border-gray-200 text-gray-400 hover:bg-white hover:border-gray-300 hover:text-gray-600' }`} > - + {option.syntheticConfigId + ?