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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,14 @@ ralph-tui create-prd --output ./docs
| `T` | Toggle subagent tree panel (Shift+T) |
| `t` | Cycle subagent detail level |
| `o` | Cycle right panel views |
| `a` | Open agent/model picker (local tab only) |
| `,` | Open settings (local tab only) |
| `C` | Open read-only config viewer (Shift+C, works on local and remote tabs) |
| `q` | Quit |
| `?` | Show help |
| `1-9` | Switch to tab 1-9 (remote instances) |
| `[` / `]` | Previous/Next tab |
| `a` | Add new remote instance |
| `A` | Add new remote instance |
| `e` | Edit current remote (when viewing remote tab) |
| `x` | Delete current remote (when viewing remote tab) |

Expand Down Expand Up @@ -393,7 +394,7 @@ The first tab is always "Local" (your current machine). Remote tabs show the ali

You can add, edit, and delete remote servers directly from the TUI without leaving the interface:

**Add Remote (`a` key):**
**Add Remote (`A` key):**
Opens a form dialog to configure a new remote:
- **Alias**: A short name for the remote (e.g., "prod", "dev-server")
- **Host**: The server address (e.g., "192.168.1.100", "server.example.com")
Expand Down
1 change: 1 addition & 0 deletions src/chat/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function createMockAgent(responseText = 'mock response'): {
getSetupQuestions: () => [],
validateSetup: async () => null,
validateModel: () => null,
listModels: () => [],
getSandboxRequirements: () => ({
authPaths: [],
binaryPaths: [],
Expand Down
44 changes: 39 additions & 5 deletions src/commands/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { useState, useEffect, useMemo } from 'react';
import { createCliRenderer } from '@opentui/core';
import { createRoot } from '@opentui/react';
import { buildConfig, validateConfig, loadStoredConfig, saveProjectConfig } from '../config/index.js';
import { buildConfig, validateConfig, loadStoredConfig, saveProjectConfig, getDefaultAgentConfig } from '../config/index.js';
import type { RuntimeOptions, StoredConfig, SandboxConfig } from '../config/types.js';
import {
checkSession,
Expand Down Expand Up @@ -258,13 +258,47 @@ export function applyConflictResolvedTaskTracking(

/**
* Propagate runtime-updateable settings from stored config to a running engine.
* Called after saving settings to ensure the engine picks up changes immediately.
* Called during settings save so invalid runtime changes fail before persistence.
*/
export function propagateSettingsToEngine(
export async function propagateSettingsToEngine(
engine: ExecutionEngine | null | undefined,
newConfig: StoredConfig,
): void {
previousConfig?: StoredConfig,
): Promise<void> {
if (!engine) return;

// Resolve agent priority: agent -> defaultAgent -> default agents[] entry -> first agents[] entry.
const getConfiguredAgentName = (config: StoredConfig | undefined): string | undefined =>
config?.agent ??
config?.defaultAgent ??
config?.agents?.find((agent) => agent.default)?.name ??
config?.agents?.[0]?.name;

// Trim model values and treat empty strings as undefined.
const normalizeModel = (model: string | undefined): string | undefined => {
const trimmed = model?.trim();
return trimmed ? trimmed : undefined;
};

const previousAgentName = getConfiguredAgentName(previousConfig);
const nextAgentName = getConfiguredAgentName(newConfig);
const previousModel = normalizeModel(previousConfig?.model);
const nextModel = normalizeModel(newConfig.model);
// Switch on initial config with agent/model, or when resolved agent/model changed.
// When true, getDefaultAgentConfig feeds engine.switchToUserAgent(agentConfig, nextModel).
const shouldSwitchAgent =
previousConfig === undefined
? nextAgentName !== undefined || nextModel !== undefined
: previousAgentName !== nextAgentName || previousModel !== nextModel;

if (shouldSwitchAgent) {
const agentConfig = getDefaultAgentConfig(newConfig, {});
if (!agentConfig) {
throw new Error('No agent configured');
}
await engine.switchToUserAgent(agentConfig, nextModel);
}

if (newConfig.autoCommit !== undefined) {
engine.setAutoCommit(newConfig.autoCommit);
}
Expand Down Expand Up @@ -1733,9 +1767,9 @@ function RunAppWrapper({

// Handle settings save
const handleSaveSettings = async (newConfig: StoredConfig): Promise<void> => {
await propagateSettingsToEngine(engine, newConfig, storedConfig);
await saveProjectConfig(newConfig, cwd);
setStoredConfig(newConfig);
propagateSettingsToEngine(engine, newConfig);
};

// Handle loading available epics (engine absent in parallel mode)
Expand Down
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ function mergeConfigs(
merged.iterationDelay = project.iterationDelay;
if (project.outputDir !== undefined) merged.outputDir = project.outputDir;
if (project.agent !== undefined) merged.agent = project.agent;
if (project.model !== undefined) merged.model = project.model;
if (project.agentCommand !== undefined)
merged.agentCommand = project.agentCommand;
if (project.command !== undefined) merged.command = project.command;
Expand Down Expand Up @@ -698,7 +699,7 @@ export async function buildConfig(
DEFAULT_CONFIG.progressFile,
epicId: options.epicId,
prdPath: options.prdPath,
model: options.model,
model: options.model ?? storedConfig.model,
showTui: !options.headless,
errorHandling,
sandbox,
Expand Down
1 change: 1 addition & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export const StoredConfigSchema = z

// Agent-specific options (shorthand for common settings)
agent: z.string().optional(),
model: z.string().optional(),
agentCommand: z.string().optional(),
/**
* Custom command/executable path for the agent.
Expand Down
3 changes: 3 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ export interface StoredConfig {
/** Shorthand: agent plugin name */
agent?: string;

/** Shorthand: model override for the selected agent */
model?: string;

/** Legacy alias: agent command name */
agentCommand?: string;

Expand Down
133 changes: 127 additions & 6 deletions src/engine/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { RateLimitDetector, type RateLimitDetectionResult } from './rate-limit-d
import type { TrackerPlugin, TrackerTask } from '../plugins/trackers/types.js';
import type {
AgentPlugin,
AgentPluginConfig,
AgentExecutionHandle,
AgentExecutionResult,
} from '../plugins/agents/types.js';
Expand Down Expand Up @@ -78,6 +79,14 @@ const PRIMARY_RECOVERY_TEST_TIMEOUT_MS = 5000;
*/
const PRIMARY_RECOVERY_TEST_PROMPT = 'Reply with just the word "ok".';

interface PendingUserAgentSwap {
agentConfig: AgentPluginConfig;
agent: AgentPlugin;
model: string | undefined;
rateLimitConfig: Required<RateLimitHandlingConfig>;
previousAgent: string;
}

/**
* Maximum characters kept for live stdout/stderr buffers in engine state.
* These buffers are for UI/remote progress display and should stay bounded.
Expand Down Expand Up @@ -246,6 +255,10 @@ export class ExecutionEngine {
private primaryAgentInstance: AgentPlugin | null = null;
/** Track agent switches during the current iteration for logging */
private currentIterationAgentSwitches: AgentSwitchEntry[] = [];
/** Agent switches to carry into the next iteration after its reset */
private nextIterationAgentSwitches: AgentSwitchEntry[] = [];
/** User-requested agent swap waiting for the active execution to finish */
private pendingUserAgentSwap: PendingUserAgentSwap | null = null;
/** Forced task for worker mode — engine only works on this one task */
private forcedTask: TrackerTask | null = null;
/** Track if the forced task has been processed (prevents infinite loop on skip/fail) */
Expand Down Expand Up @@ -933,7 +946,8 @@ export class ExecutionEngine {
this.subagentParser.reset();

// Reset agent switch tracking for this iteration
this.currentIterationAgentSwitches = [];
this.currentIterationAgentSwitches = this.nextIterationAgentSwitches;
this.nextIterationAgentSwitches = [];

const startedAt = new Date();
const iteration = this.state.currentIteration;
Expand Down Expand Up @@ -1442,6 +1456,8 @@ export class ExecutionEngine {
// Ignore cleanup errors for temporary files
});
}
this.currentExecution = null;
this.applyPendingUserAgentSwap();
this.state.currentTask = null;
}
}
Expand Down Expand Up @@ -1863,10 +1879,16 @@ export class ExecutionEngine {
* Updates state, emits agent:switched event, and persists across iterations.
*
* @param newAgentPlugin - Plugin identifier of the agent to switch to
* @param reason - Why the switch is happening (primary recovery or fallback)
* @param reason - Why the switch is happening
*/
private switchAgent(newAgentPlugin: string, reason: ActiveAgentReason): void {
const previousAgent = this.state.activeAgent?.plugin ?? this.config.agent.plugin;
private switchAgent(
newAgentPlugin: string,
reason: ActiveAgentReason,
previousAgentOverride?: string,
recordForNextIteration = false
): void {
const previousAgent =
previousAgentOverride ?? this.state.activeAgent?.plugin ?? this.config.agent.plugin;
const now = new Date().toISOString();

// Update active agent state
Expand All @@ -1889,6 +1911,10 @@ export class ExecutionEngine {
primaryAgent: this.state.rateLimitState.primaryAgent,
// Clear limitedAt and fallbackAgent on recovery
};
} else if (reason === 'user-selected') {
this.state.rateLimitState = {
primaryAgent: newAgentPlugin,
};
}

// Record the agent switch for iteration logging
Expand All @@ -1898,14 +1924,18 @@ export class ExecutionEngine {
to: newAgentPlugin,
reason,
};
this.currentIterationAgentSwitches.push(switchEntry);
if (recordForNextIteration) {
this.nextIterationAgentSwitches.push(switchEntry);
} else {
this.currentIterationAgentSwitches.push(switchEntry);
}

// Log the switch to console for visibility
if (reason === 'fallback') {
console.log(
`[agent-switch] Switching to fallback: ${previousAgent} → ${newAgentPlugin} (rate limit)`
);
} else {
} else if (reason === 'primary') {
// Calculate duration on fallback for recovery logging
let durationOnFallback = '';
if (this.state.rateLimitState?.limitedAt) {
Expand All @@ -1923,6 +1953,10 @@ export class ExecutionEngine {
console.log(
`[agent-switch] Recovering to primary: ${previousAgent} → ${newAgentPlugin}${durationOnFallback}`
);
} else {
console.log(
`[agent-switch] User selected agent: ${previousAgent} → ${newAgentPlugin}`
);
}

// Emit agent switched event
Expand Down Expand Up @@ -2083,6 +2117,89 @@ export class ExecutionEngine {
this.switchAgent(fallbackAgentPlugin, 'fallback');
}

/**
* Switch to a user-selected agent for subsequent iterations.
* Validates availability and model compatibility before mutating engine state.
*
* @param agentConfig - Agent configuration to activate
* @param model - Optional model override; undefined clears the runtime model override
*/
async switchToUserAgent(
agentConfig: AgentPluginConfig,
model: string | undefined
): Promise<void> {
const normalizedModel = model?.trim() ? model.trim() : undefined;

const agentRegistry = getAgentRegistry();
const newInstance = await agentRegistry.getInstance(agentConfig);

const detectResult = await newInstance.detect();
if (!detectResult.available) {
throw new Error(
`Agent '${agentConfig.plugin}' not available: ${detectResult.error ?? 'detection failed'}`
);
}

if (normalizedModel !== undefined) {
const modelError = newInstance.validateModel(normalizedModel);
if (modelError) {
throw new Error(modelError);
}
}
Comment thread
subsy marked this conversation as resolved.

const previousAgent = this.state.activeAgent?.plugin ?? this.config.agent.plugin;
const swap: PendingUserAgentSwap = {
agentConfig,
agent: newInstance,
model: normalizedModel,
rateLimitConfig: {
...DEFAULT_RATE_LIMIT_HANDLING,
...agentConfig.rateLimitHandling,
},
previousAgent,
};

if (this.currentExecution || this.state.currentTask) {
this.pendingUserAgentSwap = swap;
return;
}

this.applyUserAgentSwap(swap, true);
}

private applyUserAgentSwap(
swap: PendingUserAgentSwap,
recordForNextIteration: boolean
): void {
this.config.agent = {
...swap.agentConfig,
options: { ...swap.agentConfig.options },
};
this.config.model = swap.model;
this.rateLimitConfig = swap.rateLimitConfig;
this.agent = swap.agent;
this.primaryAgentInstance = swap.agent;
this.rateLimitedAgents.clear();
this.state.currentModel = swap.model;

this.switchAgent(
swap.agentConfig.plugin,
'user-selected',
swap.previousAgent,
recordForNextIteration
);
}

private applyPendingUserAgentSwap(): void {
if (!this.pendingUserAgentSwap) {
return;
}

const swap = this.pendingUserAgentSwap;
this.pendingUserAgentSwap = null;
this.applyUserAgentSwap(swap, true);
}

/**
* Get the next available fallback agent that hasn't been rate-limited.
* Returns undefined if no fallback agents are configured or all are rate-limited.
Expand Down Expand Up @@ -2206,6 +2323,10 @@ export class ExecutionEngine {
return `${statusWord} on primary (${currentAgent}) after recovery`;
}

if (lastSwitch && lastSwitch.reason === 'user-selected') {
return `${statusWord} on user-selected agent (${currentAgent})`;
}

// Generic summary for other cases
return `${statusWord} with ${this.currentIterationAgentSwitches.length} agent switch(es)`;
}
Expand Down
Loading
Loading