Skip to content
5 changes: 5 additions & 0 deletions .changeset/resume-subagent-after-restart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Resuming a subagent by its agent id now works after the session is reopened in a new process; the resumed subagent follows the current permission mode and is matched by its own profile in permission rules.
63 changes: 45 additions & 18 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import {
ToolAccesses,
type ExecutableToolContext,
Expand All @@ -41,11 +42,18 @@ import {
withoutDelegatingTargets,
} from '#/app/agentProfileCatalog/profile-shared';
import { ILogService } from '#/_base/log/log';
import { hasPinnedPermissionMode } from '#/features/tower/tower';
import { IConfigService } from '#/app/config/config';
import { IFlagService } from '#/app/flag/flag';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import {
isSubagentMeta,
labelsFromAgentMeta,
subagentLabels,
subagentParentAgentId,
subagentProfileName,
} from '#/session/agentLifecycle/subagentMetadata';
import { type AgentMeta, ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';

import { emitAgentRunSpawned, mirrorAgentRun, SubagentStarted } from '#/session/subagent/mirrorAgentRun';
import { IEventDispatcher } from '#/state/eventDispatcher';
Expand Down Expand Up @@ -109,6 +117,7 @@ export class SubagentTool implements ISubagentTool {
@IAgentProfileService private readonly profile: IAgentProfileService,
@IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService,
@IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService,
@IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService,
@ISessionMetadata private readonly sessionMetadata: ISessionMetadata,
@ILogService private readonly log: ILogService,
@IConfigService private readonly config: IConfigService,
Expand Down Expand Up @@ -232,7 +241,7 @@ export class SubagentTool implements ISubagentTool {

const profileNameForDisplay =
resumeAgentId !== undefined && resumeAgentId.length > 0
? this.resumeProfileName(resumeAgentId) ?? RESUMED_LABEL
? (await this.resumeProfileName(resumeAgentId)) ?? RESUMED_LABEL
: (requestedProfileName ??
(args.fork === true
? (this.profile.data().profileName ?? DEFAULT_PROFILE_NAME)
Expand All @@ -253,10 +262,10 @@ export class SubagentTool implements ISubagentTool {
};
}

private resumeProfileName(agentId: string): string | undefined {
private async resumeProfileName(agentId: string): Promise<string | undefined> {
const target = this.agentLifecycle.handleOf(agentId);
if (target === undefined) return undefined;
return target.accessor.get(IAgentProfileService).data().profileName;
if (target !== undefined) return target.accessor.get(IAgentProfileService).data().profileName;
return subagentProfileName((await this.sessionMetadata.read()).agents?.[agentId]);
Comment thread
RealKai42 marked this conversation as resolved.
}

private async launch(
Expand All @@ -282,13 +291,7 @@ export class SubagentTool implements ISubagentTool {
let displayModelSource: SubagentModelSource | undefined;
let promptText = args.prompt;
if (isResume) {
const target = this.agentLifecycle.handleOf(resumeAgentId);
if (target === undefined) {
throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, {
details: { agentId: resumeAgentId },
});
}
await this.ensureOwnedIdleSubagent(resumeAgentId, target);
const target = await this.resolveResumeTarget(resumeAgentId);
agentId = target.id;
const resumed = target.accessor.get(IAgentProfileService).data();
profileName = resumed.profileName ?? RESUMED_LABEL;
Expand Down Expand Up @@ -346,12 +349,15 @@ export class SubagentTool implements ISubagentTool {
};
}

private async ensureOwnedIdleSubagent(
agentId: string,
target: IAgentScopeHandle,
): Promise<void> {
private async resolveResumeTarget(agentId: string): Promise<IAgentScopeHandle> {
const live = this.agentLifecycle.handleOf(agentId);
const meta = (await this.sessionMetadata.read()).agents?.[agentId];
if (!isSubagentMeta(meta)) {
if (meta === undefined && live === undefined) {
throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, {
details: { agentId },
});
}
if (meta === undefined || !isSubagentMeta(meta)) {
throw new Error2(ErrorCodes.AGENT_NOT_A_SUBAGENT, `Agent instance "${agentId}" is not a subagent`, {
details: { agentId },
});
Expand All @@ -363,13 +369,34 @@ export class SubagentTool implements ISubagentTool {
{ details: { agentId, callerAgentId: this.callerAgentId } },
);
}
const target = live ?? (await this.rebuildSubagent(agentId, meta));
Comment thread
RealKai42 marked this conversation as resolved.
if (target.accessor.get(IAgentLoopService).status().state === 'running') {
throw new Error2(
ErrorCodes.AGENT_ALREADY_RUNNING,
`Agent instance "${agentId}" is already running and cannot run concurrently`,
{ details: { agentId } },
);
}
return target;
}

private async rebuildSubagent(agentId: string, meta: AgentMeta): Promise<IAgentScopeHandle> {
await this.agentLifecycle.create({
agentId,
labels: labelsFromAgentMeta(meta),
forkedFrom: meta.forkedFrom,
});
Comment thread
RealKai42 marked this conversation as resolved.
const rebuilt = this.agentLifecycle.handleOf(agentId);
if (rebuilt === undefined) {
throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${agentId}" does not exist`, {
details: { agentId },
});
}
if (!hasPinnedPermissionMode(rebuilt.accessor.get(IAgentProfileService).data().profileName)) {
rebuilt.accessor.get(IAgentPermissionModeService).setMode(this.permissionMode.mode);
}
this.log.info('subagent rebuilt for resume', { agentId, callerAgentId: this.callerAgentId });
return rebuilt;
}

private async execution(
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/features/tower/tower.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export const TOWER_TOOL_NAMES = [

export const TOWER_WORKER_PROFILE = 'tower-worker';

export function hasPinnedPermissionMode(profileName: string | undefined): boolean {
return profileName === TOWER_WORKER_PROFILE;
}

export const TOWER_FLAG_ID = 'tower';

export type TowerEnterFailure =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSe
import { permissionModeConfiguredKey } from '#/agent/permissionMode/permissionModeOps';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { profileKey } from '#/agent/profile/profileOps';
import { TOWER_WORKER_PROFILE } from '#/features/tower/tower';
import { hasPinnedPermissionMode } from '#/features/tower/tower';
import { IAgentTaskService } from '#/agent/task/task';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { withSubagentProfile } from '#/session/agentLifecycle/subagentMetadata';
import {
agentContextOf,
IAgentScopeContext,
Expand Down Expand Up @@ -193,7 +194,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
type: agentId === 'main' ? 'main' : 'sub',
parentAgentId: agentId === 'main' ? undefined : 'main',
forkedFrom: opts.forkedFrom,
labels: opts.labels,
labels: withSubagentProfile(
opts.labels,
agentId === 'main' ? undefined : opts.binding?.profile,
),
});
this.onDidCreateEmitter.fire(agent);
didCreate = true;
Expand Down Expand Up @@ -263,17 +267,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
});
}
const source = sourceManaged.handle;
const sourceData = source.accessor.get(IAgentProfileService).data();
const override = opts?.binding;
const childContext = await this.create({
agentId: opts?.agentId,
runtimeId: source.accessor.get(IAgentRuntimeBindingService).current.runtimeId,
forkedFrom: source.id,
labels: opts?.labels,
labels: withSubagentProfile(opts?.labels, override?.profile ?? sourceData.profileName),
});
const child = this.requireManaged(childContext).handle;

const sourceData = source.accessor.get(IAgentProfileService).data();
const childProfile = child.accessor.get(IAgentProfileService);
const override = opts?.binding;
if (override?.profile !== undefined) {
await childProfile.bind({
profile: override.profile,
Expand Down Expand Up @@ -314,10 +318,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
for (const managed of this.roster.values()) {
if (managed.closing || !managed.active) continue;
const handle = managed.handle;
if (
handle.accessor.get(IAgentStateService).get(profileKey).profileName ===
TOWER_WORKER_PROFILE
) {
if (hasPinnedPermissionMode(handle.accessor.get(IAgentStateService).get(profileKey).profileName)) {
continue;
}
handle.accessor.get(IAgentPermissionModeService).setMode(mode);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export function subagentLabels(
return labels;
}

export function withSubagentProfile(
labels: Readonly<Record<string, string>> | undefined,
profileName: string | undefined,
): Readonly<Record<string, string>> | undefined {
if (profileName === undefined || profileName.length === 0) return labels;
return { ...labels, profileName };
}

export function labelsFromAgentMeta(
meta: AgentMeta,
): Readonly<Record<string, string>> | undefined {
Expand Down Expand Up @@ -42,6 +50,11 @@ export function subagentSwarmItem(meta: AgentMeta | undefined): string | undefin
return firstNonEmpty(meta.labels?.['swarmItem'], meta.swarmItem);
}

export function subagentProfileName(meta: AgentMeta | undefined): string | undefined {
if (meta === undefined) return undefined;
return firstNonEmpty(meta.labels?.['profileName']);
}

function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined {
return values.find((value) => value !== undefined && value.length > 0);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,56 @@ describe('AgentLifecycleService', () => {
);
});

it('records the bound profile in the subagent metadata at registration', async () => {
const svc = ix.get(IAgentLifecycleService);

await svc
.create({
agentId: 'child',
binding: { profile: 'coder', model: 'kimi-test' },
labels: { parentAgentId: 'main' },
})
.catch(() => undefined);

expect(registerAgent).toHaveBeenCalledWith(
'child',
expect.objectContaining({
type: 'sub',
labels: { parentAgentId: 'main', profileName: 'coder' },
}),
);
});

it('fork records the inherited profile in the subagent metadata', async () => {
const svc = ix.get(IAgentLifecycleService);
const source = await svc.create({ agentId: 'main' });
svc.handleOf('main')!.accessor.get(IAgentProfileService).applyBindingSnapshot({
profileName: 'coder',
thinkingLevel: 'off',
systemPrompt: 'coder prompt',
activeToolNames: ['Read'],
disallowedTools: [],
subagents: undefined,
});

await svc.fork(agentContextOf(svc.handleOf(source.agentId)!), {
agentId: 'forked',
labels: { parentAgentId: 'main' },
});

expect(registerAgent).toHaveBeenCalledWith(
'forked',
expect.objectContaining({
forkedFrom: 'main',
labels: { parentAgentId: 'main', profileName: 'coder' },
}),
);
expect(registerAgent).toHaveBeenCalledWith(
'main',
expect.objectContaining({ type: 'main', labels: undefined }),
);
});

it('run throws when the agent does not exist', () => {
ix.set(ISessionSubagentService, new SyncDescriptor(SessionSubagentService));
const svc = ix.get(ISessionSubagentService);
Expand Down
Loading
Loading