qwen-code/packages/core/src/agents/backends/InProcessBackend.ts
Reid d1c8dff4d2
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
feat(arena): add comparison summary for agent results (#3394)
Adds a summary view that runs after Arena agents finish, so users can
compare model outputs without opening each agent's conversation first.

Summary surface:
- Agent status overview
- Files changed in common vs. unique to one agent
- Per-agent approach summary generated through that agent's own provider
- Token / runtime / line-change / file-count metrics

Selection dialog now supports:
- p — toggle preview for the highlighted agent
- d — toggle detailed diff
- Enter — select winner
- x — discard all results
- Esc — cancel

Approach summary generation:
- Each agent's summary is generated through that agent's own content
  generator, keeping mixed-provider Arena sessions within their
  respective auth boundaries
- 20s timeout + AbortController per agent, bounded prompt inputs
  (finalText 2K, transcript 6K, diff 6K)
- Falls back to a deterministic "Changed N files ..." summary when no
  per-agent generator is available or on error

Diff summary now handles binary, rename-only, and mode-only diffs;
the previous heuristic required textual +/- hunks and would have
dropped those.

Resolves #2559
2026-04-22 05:31:19 +08:00

425 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview InProcessBackend — Backend implementation that runs agents
* in the current process using AgentInteractive instead of PTY subprocesses.
*
* This enables Arena to work without tmux or any external terminal multiplexer.
*/
import { createDebugLogger } from '../../utils/debugLogger.js';
import type { Config } from '../../config/config.js';
import {
type AuthType,
type ContentGenerator,
type ContentGeneratorConfig,
createContentGenerator,
} from '../../core/contentGenerator.js';
import type { ToolRegistry } from '../../tools/tool-registry.js';
import { WorkspaceContext } from '../../utils/workspaceContext.js';
import { FileDiscoveryService } from '../../services/fileDiscoveryService.js';
import { buildAgentContentGeneratorConfig } from '../../models/content-generator-config.js';
import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js';
import { AgentCore } from '../runtime/agent-core.js';
import { AgentEventEmitter } from '../runtime/agent-events.js';
import { ContextState } from '../runtime/agent-headless.js';
import { AgentInteractive } from '../runtime/agent-interactive.js';
import type {
Backend,
AgentSpawnConfig,
AgentExitCallback,
InProcessSpawnConfig,
} from './types.js';
import { DISPLAY_MODE } from './types.js';
import type { AnsiOutput } from '../../utils/terminalSerializer.js';
const debugLogger = createDebugLogger('IN_PROCESS_BACKEND');
/**
* InProcessBackend runs agents in the current Node.js process.
*
* Instead of spawning PTY subprocesses, it creates AgentCore + AgentInteractive
* instances that execute in-process. Screen capture returns null (the UI reads
* messages directly from AgentInteractive).
*/
export class InProcessBackend implements Backend {
readonly type = DISPLAY_MODE.IN_PROCESS;
private readonly runtimeContext: Config;
private readonly agents = new Map<string, AgentInteractive>();
private readonly agentContentGenerators = new Map<string, ContentGenerator>();
private readonly agentRegistries: ToolRegistry[] = [];
private readonly agentOrder: string[] = [];
private activeAgentId: string | null = null;
private exitCallback: AgentExitCallback | null = null;
/** Whether cleanup() has been called */
private cleanedUp = false;
constructor(runtimeContext: Config) {
this.runtimeContext = runtimeContext;
}
// ─── Backend Interface ─────────────────────────────────────
async init(): Promise<void> {
debugLogger.info('InProcessBackend initialized');
}
async spawnAgent(config: AgentSpawnConfig): Promise<void> {
const inProcessConfig = config.inProcess;
if (!inProcessConfig) {
throw new Error(
`InProcessBackend requires inProcess config for agent ${config.agentId}`,
);
}
if (this.agents.has(config.agentId)) {
throw new Error(`Agent "${config.agentId}" already exists.`);
}
const { promptConfig, modelConfig, runConfig, toolConfig } =
inProcessConfig.runtimeConfig;
const eventEmitter = new AgentEventEmitter();
// Build a per-agent runtime context with isolated working directory,
// target directory, workspace context, tool registry, and (optionally)
// a dedicated ContentGenerator for per-agent auth isolation.
const perAgent = await createPerAgentConfig(
this.runtimeContext,
config.cwd,
inProcessConfig.runtimeConfig.modelConfig.model,
inProcessConfig.authOverrides,
);
const agentContext = perAgent.config;
if (perAgent.contentGenerator) {
this.agentContentGenerators.set(
config.agentId,
perAgent.contentGenerator,
);
}
this.agentRegistries.push(agentContext.getToolRegistry());
const core = new AgentCore(
inProcessConfig.agentName,
agentContext,
promptConfig,
modelConfig,
runConfig,
toolConfig,
eventEmitter,
);
const interactive = new AgentInteractive(
{
agentId: config.agentId,
agentName: inProcessConfig.agentName,
initialTask: inProcessConfig.initialTask,
maxTurnsPerMessage: runConfig.max_turns,
maxTimeMinutesPerMessage: runConfig.max_time_minutes,
chatHistory: inProcessConfig.chatHistory,
},
core,
);
this.agents.set(config.agentId, interactive);
this.agentOrder.push(config.agentId);
// Set first agent as active
if (this.activeAgentId === null) {
this.activeAgentId = config.agentId;
}
try {
const context = new ContextState();
await interactive.start(context);
// Watch for completion and fire exit callback — but only for
// truly terminal statuses. IDLE means the agent is still alive
// and can accept follow-up messages.
void interactive.waitForCompletion().then(() => {
const status = interactive.getStatus();
if (!isTerminalStatus(status)) {
return;
}
const exitCode =
status === AgentStatus.COMPLETED
? 0
: status === AgentStatus.FAILED
? 1
: null;
this.exitCallback?.(config.agentId, exitCode, null);
});
debugLogger.info(`Spawned in-process agent: ${config.agentId}`);
} catch (error) {
debugLogger.error(
`Failed to start in-process agent "${config.agentId}":`,
error,
);
this.exitCallback?.(config.agentId, 1, null);
}
}
stopAgent(agentId: string): void {
const agent = this.agents.get(agentId);
if (agent) {
agent.abort();
debugLogger.info(`Stopped agent: ${agentId}`);
}
}
stopAll(): void {
for (const agent of this.agents.values()) {
agent.abort();
}
debugLogger.info('Stopped all in-process agents');
}
async cleanup(): Promise<void> {
this.cleanedUp = true;
for (const agent of this.agents.values()) {
agent.abort();
}
// Wait for loops to settle, but cap at 3s so CLI exit isn't blocked
// if an agent's reasoning loop doesn't terminate promptly after abort.
const CLEANUP_TIMEOUT_MS = 3000;
const promises = Array.from(this.agents.values()).map((a) =>
a.waitForCompletion().catch(() => {}),
);
let timerId: ReturnType<typeof setTimeout>;
const timeout = new Promise<void>((resolve) => {
timerId = setTimeout(resolve, CLEANUP_TIMEOUT_MS);
});
await Promise.race([Promise.allSettled(promises), timeout]);
clearTimeout(timerId!);
// Stop per-agent tool registries so tools like AgentTool can release
// listeners registered on shared managers (e.g. SubagentManager).
for (const registry of this.agentRegistries) {
await registry.stop().catch(() => {});
}
this.agentRegistries.length = 0;
this.agents.clear();
this.agentContentGenerators.clear();
this.agentOrder.length = 0;
this.activeAgentId = null;
debugLogger.info('InProcessBackend cleaned up');
}
setOnAgentExit(callback: AgentExitCallback): void {
this.exitCallback = callback;
}
async waitForAll(timeoutMs?: number): Promise<boolean> {
if (this.cleanedUp) return true;
const promises = Array.from(this.agents.values()).map((a) =>
a.waitForCompletion(),
);
if (timeoutMs === undefined) {
await Promise.allSettled(promises);
return true;
}
let timerId: ReturnType<typeof setTimeout>;
const timeout = new Promise<'timeout'>((resolve) => {
timerId = setTimeout(() => resolve('timeout'), timeoutMs);
});
const result = await Promise.race([
Promise.allSettled(promises).then(() => 'done' as const),
timeout,
]);
clearTimeout(timerId!);
return result === 'done';
}
// ─── Navigation ────────────────────────────────────────────
switchTo(agentId: string): void {
if (this.agents.has(agentId)) {
this.activeAgentId = agentId;
}
}
switchToNext(): void {
this.activeAgentId = this.navigate(1);
}
switchToPrevious(): void {
this.activeAgentId = this.navigate(-1);
}
getActiveAgentId(): string | null {
return this.activeAgentId;
}
// ─── Screen Capture (no-op for in-process) ─────────────────
getActiveSnapshot(): AnsiOutput | null {
return null;
}
getAgentSnapshot(
_agentId: string,
_scrollOffset?: number,
): AnsiOutput | null {
return null;
}
getAgentScrollbackLength(_agentId: string): number {
return 0;
}
// ─── Input ─────────────────────────────────────────────────
forwardInput(data: string): boolean {
if (!this.activeAgentId) return false;
return this.writeToAgent(this.activeAgentId, data);
}
writeToAgent(agentId: string, data: string): boolean {
const agent = this.agents.get(agentId);
if (!agent) return false;
agent.enqueueMessage(data);
return true;
}
// ─── Resize (no-op) ───────────────────────────────────────
resizeAll(_cols: number, _rows: number): void {
// No terminals to resize in-process
}
// ─── External Session ──────────────────────────────────────
getAttachHint(): string | null {
return null;
}
// ─── Extra: Direct Access ──────────────────────────────────
/**
* Get an AgentInteractive instance by agent ID.
* Used by ArenaManager for direct event subscription.
*/
getAgent(agentId: string): AgentInteractive | undefined {
return this.agents.get(agentId);
}
/**
* Get the ContentGenerator this agent can use for summary generation.
* If auth overrides created an isolated generator, this returns that
* generator. If no override was requested, this returns the inherited
* generator the agent already runs with. If override creation failed, this is
* undefined so callers can avoid sending agent data through a fallback
* provider.
*/
getAgentContentGenerator(agentId: string): ContentGenerator | undefined {
return this.agentContentGenerators.get(agentId);
}
// ─── Private ───────────────────────────────────────────────
private navigate(direction: 1 | -1): string | null {
if (this.agentOrder.length === 0) return null;
if (!this.activeAgentId) return this.agentOrder[0] ?? null;
const currentIndex = this.agentOrder.indexOf(this.activeAgentId);
if (currentIndex === -1) return this.agentOrder[0] ?? null;
const nextIndex =
(currentIndex + direction + this.agentOrder.length) %
this.agentOrder.length;
return this.agentOrder[nextIndex] ?? null;
}
}
/**
* Create a per-agent Config that delegates to the shared base Config but
* overrides key methods to provide per-agent isolation:
*
* - `getWorkingDir()` / `getTargetDir()` → agent's worktree cwd
* - `getWorkspaceContext()` → WorkspaceContext rooted at agent's cwd
* - `getFileService()` → FileDiscoveryService rooted at agent's cwd
* - `getToolRegistry()` → per-agent tool registry with core tools bound to
* the agent Config
* - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()`
* → per-agent ContentGenerator when `authOverrides` is provided
* - returned `contentGenerator` → the generator safe to use for summaries
*/
async function createPerAgentConfig(
base: Config,
cwd: string,
modelId?: string,
authOverrides?: InProcessSpawnConfig['authOverrides'],
): Promise<{ config: Config; contentGenerator?: ContentGenerator }> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const override = Object.create(base) as any;
let dedicatedContentGenerator: ContentGenerator | undefined;
override.getWorkingDir = () => cwd;
override.getTargetDir = () => cwd;
override.getProjectRoot = () => cwd;
const agentWorkspace = new WorkspaceContext(cwd);
override.getWorkspaceContext = () => agentWorkspace;
const agentFileService = new FileDiscoveryService(cwd);
override.getFileService = () => agentFileService;
const agentRegistry: ToolRegistry = await override.createToolRegistry(
undefined,
{ skipDiscovery: true },
);
agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry());
override.getToolRegistry = () => agentRegistry;
if (authOverrides?.authType) {
try {
const agentGeneratorConfig = buildAgentContentGeneratorConfig(
base,
modelId,
authOverrides,
);
const agentGenerator = await createContentGenerator(
agentGeneratorConfig,
override as Config,
);
dedicatedContentGenerator = agentGenerator;
override.getContentGenerator = (): ContentGenerator => agentGenerator;
override.getContentGeneratorConfig = (): ContentGeneratorConfig =>
agentGeneratorConfig;
override.getAuthType = (): AuthType | undefined =>
agentGeneratorConfig.authType;
override.getModel = (): string => agentGeneratorConfig.model;
debugLogger.info(
`Created per-agent ContentGenerator: authType=${authOverrides.authType}, model=${agentGeneratorConfig.model}`,
);
} catch (error) {
debugLogger.error(
'Failed to create per-agent ContentGenerator, falling back to parent:',
error,
);
}
}
return {
config: override as Config,
contentGenerator:
dedicatedContentGenerator ??
(authOverrides?.authType ? undefined : base.getContentGenerator()),
};
}