SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
68d7816 verified
Raw History Blame Contribute Delete
9.78 kB
import { createHash, randomUUID } from 'node:crypto';
import { dirname, join } from 'pathe';
import { type IDisposable } from '#/_base/di/lifecycle';
import { Service } from '#/_base/di/service';
import { unwrapErrorCause } from '#/_base/errors/errors';
import { Error2, ErrorCodes } from '#/errors';
import { generateHeroSlug } from '#/_base/utils/hero-slug';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { IAgentReminderService } from '#/features/reminder/reminderService';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import { PlanModeInjection } from '#/features/plan/injection/planModeInjection';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentStateService } from '#/agent/state/agentState';
import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval';
import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent';
import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor';
import type {
BeforeToolExecuteEvent,
ResolvedToolExecutionHookContext,
} from '#/agent/toolExecutor/toolHooks';
import { IEventBus } from '#/app/event/eventBus';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { IHostFileSystem } from '#/os/interface/hostFileSystem';
import { IBlobStore } from '#/persistence/interface/blobStore';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { IEventDispatcher } from '#/state/eventDispatcher';
import { AgentStatusUpdated } from '#/agent/usage/usageEvents';
import { ContextUndone } from '#/agent/undo/undoService';
import type { ToolFileAccess } from '#/tool/toolContract';
import {
IAgentPlanService,
type PlanData,
type PlanFilePath,
} from './plan';
import { ExitPlanModeReview } from './exitPlanModeReview';
import {
PlanModeCancel,
PlanModeEnter,
PlanModeExit,
planKey,
PlanRevision,
} from './planOps';
export class AgentPlanService extends Service implements IAgentPlanService {
declare readonly _serviceBrand: undefined;
private readonly review: ExitPlanModeReview;
constructor(
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IHostFileSystem private readonly hostFs: IHostFileSystem,
@IBlobStore private readonly blobs: IBlobStore,
@IAgentReminderService reminder: IAgentReminderService,
@IEventBus eventBus: IEventBus,
@IEventDispatcher private readonly dispatcher: IEventDispatcher,
@ISessionContext private readonly sessionCtx: ISessionContext,
@IAgentScopeContext private readonly agentCtx: IAgentScopeContext,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
@IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService,
@IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentStateService private readonly agentState: IAgentStateService,
) {
super();
this.agentState.contributeState(planKey);
this.review = new ExitPlanModeReview(this, this.toolApproval, telemetry);
this._register(
this.dispatcher.hooks.onDidRestore.register('plan', async (_ctx, next) => {
this.restoreTelemetryMode();
await next();
}),
);
this._register(
eventBus.subscribe(ContextUndone, () => {
this.restoreTelemetryMode();
void this.dispatcher.dispatch(
new AgentStatusUpdated({ agentId: this.agentCtx.agentId, planMode: this.isActive }),
);
}),
);
this._register(new PlanModeInjection(reminder, this, this.context, agentState));
this._register(this.registerPlanGuard(toolExecutor));
}
private registerPlanGuard(toolExecutor: IAgentToolExecutorService): IDisposable {
return toolExecutor.onBeforeExecuteTool((event) => this.guardToolExecution(event));
}
private async guardToolExecution(event: BeforeToolExecuteEvent): Promise<void> {
const toolName = event.toolCall.name;
const plan = await this.status();
if (toolName === 'ExitPlanMode') {
if (plan !== null && this.modeService.mode !== 'auto') {
event.waitUntil(() => this.review.requestApproval(event));
}
return;
}
if (plan === null) {
return;
}
if (toolName === 'Write' || toolName === 'Edit') {
if (writesOnlyPlanFile(event, plan.path)) {
event.allow();
return;
}
event.veto(
denyToolExecution(this.toolApproval.formatDenyMessage(planModeWriteDeniedMessage(plan.path))),
);
return;
}
if (toolName === 'TaskStop') {
event.veto(
denyToolExecution(
this.toolApproval.formatDenyMessage(
'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.',
),
),
);
return;
}
if (toolName === 'CronCreate' || toolName === 'CronDelete') {
event.veto(
denyToolExecution(
this.toolApproval.formatDenyMessage(
`${toolName} is not available in plan mode because it would mutate scheduled work that runs after plan exit. Call ExitPlanMode first.`,
),
),
);
return;
}
}
private get isActive(): boolean {
return this.agentState.get(planKey).active;
}
private currentPlanFilePath(): PlanFilePath {
const state = this.agentState.get(planKey);
if (!state.active || state.id === undefined) return null;
return this.planFilePathFor(state.id);
}
private restoreTelemetryMode(): void {
this.telemetry.setContext({ mode: this.isActive ? 'plan' : 'agent' });
}
private createPlanId(): string {
return generateHeroSlug(randomUUID(), new Set());
}
async enter(id = this.createPlanId(), createFile = false): Promise<void> {
if (this.isActive) {
throw new Error2(ErrorCodes.SESSION_PLAN_MODE_INVALID, 'Already in plan mode');
}
const planFilePath = this.planFilePathFor(id);
let enterRecorded = false;
try {
await this.ensurePlanDirectory(planFilePath);
await this.dispatcher.dispatch(new PlanModeEnter({ agentId: this.agentCtx.agentId, id }));
this.telemetry.setContext({ mode: 'plan' });
enterRecorded = true;
if (createFile) {
await this.writeEmptyPlanFile(planFilePath);
}
} catch (error) {
if (enterRecorded) {
this.cancel(id);
}
throw error;
}
}
cancel(id?: string): void {
void this.dispatcher.dispatch(new PlanModeCancel({ agentId: this.agentCtx.agentId, id }));
this.telemetry.setContext({ mode: 'agent' });
}
async clear(): Promise<void> {
const path = this.currentPlanFilePath();
if (path === null) return;
await this.writeEmptyPlanFile(path);
}
exit(id?: string): void {
void this.dispatcher.dispatch(new PlanModeExit({ agentId: this.agentCtx.agentId, id }));
this.telemetry.setContext({ mode: 'agent' });
}
async recordRevision(): Promise<void> {
const state = this.agentState.get(planKey);
if (!state.active || state.id === undefined) return;
const id = state.id;
const content = await this.hostFs.readText(this.planFilePathFor(id));
const bytes = Buffer.from(content, 'utf8');
const version = (state.revisionCount?.[id] ?? 0) + 1;
const scope = this.agentCtx.scope();
const key = `plan/${id}/v${version}.md`;
await this.blobs.put(scope, key, bytes);
await this.dispatcher.dispatch(
new PlanRevision({
agentId: this.agentCtx.agentId,
id,
version,
key,
sha256: createHash('sha256').update(bytes).digest('hex'),
bytes: bytes.byteLength,
}),
);
}
async status(): Promise<PlanData> {
const state = this.agentState.get(planKey);
if (!state.active || state.id === undefined) return null;
const path = this.planFilePathFor(state.id);
let content = '';
try {
content = await this.hostFs.readText(path);
} catch (error) {
if (!isMissingFileError(error)) throw error;
}
return {
id: state.id,
content,
path,
};
}
private planFilePathFor(id: string): string {
return join(this.sessionCtx.sessionDir, 'agents', this.agentCtx.agentId, 'plans', `${id}.md`);
}
private async writeEmptyPlanFile(path: string): Promise<void> {
await this.ensurePlanDirectory(path);
await this.hostFs.writeText(path, '');
}
private async ensurePlanDirectory(path: string): Promise<void> {
await this.hostFs.mkdir(dirname(path), { recursive: true });
}
}
function isMissingFileError(error: unknown): boolean {
const unwrapped = unwrapErrorCause(error);
if (unwrapped === null || typeof unwrapped !== 'object') return false;
const code = (unwrapped as { readonly code?: unknown }).code;
return code === 'ENOENT';
}
function writesOnlyPlanFile(
context: ResolvedToolExecutionHookContext,
planFilePath: string,
): boolean {
const writeAccesses = (context.execution.accesses ?? []).filter(
(access): access is ToolFileAccess =>
access.kind === 'file' &&
(access.operation === 'write' || access.operation === 'readwrite'),
);
if (writeAccesses.length === 0) return false;
return writeAccesses.every((access) => access.path === planFilePath);
}
function planModeWriteDeniedMessage(planFilePath: string | null): string {
return (
`Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` +
'Call ExitPlanMode to exit plan mode before editing other files.'
);
}
export { AgentPlanService as Plan };