Skip to content
Open
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: 5 additions & 0 deletions .changeset/web-stop-swarm-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Allow active swarm members to be stopped individually, including cancellation of their running shell commands.
1 change: 1 addition & 0 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ provide(
'resolveSwarmMembers',
(toolCallId: string): SwarmMember[] => client.swarmMembersByToolCallId.value.get(toolCallId) ?? [],
);
provide('cancelSubagent', (agentId: string): Promise<void> => client.cancelTask(agentId));
const { t } = useI18n();

// KAP/daemon debug panel — opt-in via ?debug=1 or localStorage kimi-web.debug=1.
Expand Down
7 changes: 4 additions & 3 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,9 +1065,10 @@ export function createAgentProjector(): AgentProjector {

case 'subagent.failed': {
const outputPreview = typeof p?.error === 'string' ? p.error : undefined;
const status = p?.cancelled === true ? 'cancelled' : 'failed';
const task = patchSubagent(s, sessionId, p?.subagentId, {
subagentPhase: 'failed',
status: 'failed',
status,
completedAt: new Date().toISOString(),
outputPreview,
});
Expand All @@ -1076,7 +1077,7 @@ export function createAgentProjector(): AgentProjector {
type: 'taskCompleted',
sessionId,
taskId: p?.subagentId ?? '',
status: 'failed',
status,
outputPreview,
});
break;
Expand Down Expand Up @@ -1152,7 +1153,7 @@ export function createAgentProjector(): AgentProjector {
: typeof info.taskId === 'number'
? String(info.taskId)
: '',
status: failed ? 'failed' : 'completed',
status: info.status === 'killed' ? 'cancelled' : failed ? 'failed' : 'completed',
// Do NOT set outputPreview here. The command is already kept on the
// task as `command`; setting outputPreview to `$ <command>` would
// clobber any real output captured by polling and prevents the UI
Expand Down
59 changes: 42 additions & 17 deletions apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { toolLabel } from '../../../lib/toolMeta';
import { parseSwarmResult } from '../../../lib/parseSwarmResult';
import { buildSwarmCardRows, type SwarmCardRow } from '../../../lib/swarmCardRows';
import Icon from '../../ui/Icon.vue';
import IconButton from '../../ui/IconButton.vue';
import StatusDot from '../../ui/StatusDot.vue';
import Tooltip from '../../ui/Tooltip.vue';

Expand Down Expand Up @@ -60,6 +61,7 @@ function parseInput(arg: string): SwarmInput {

const resolveSwarmMembers =
inject<(toolCallId: string) => SwarmMember[] | undefined>('resolveSwarmMembers');
const cancelSubagent = inject<(agentId: string) => Promise<void>>('cancelSubagent');

const input = computed(() => parseInput(props.tool.arg));
const label = computed(() => toolLabel(props.tool.name));
Expand Down Expand Up @@ -151,6 +153,10 @@ function isRowOpen(id: string): boolean {
function phaseLabel(phase: AppSubagentPhase): string {
return t(`tools.swarm.phase${phase[0]!.toUpperCase()}${phase.slice(1)}`);
}

function stopSubagent(agentId: string): void {
void cancelSubagent?.(agentId);
}
</script>

<template>
Expand Down Expand Up @@ -201,22 +207,33 @@ function phaseLabel(phase: AppSubagentPhase): string {
class="member"
:class="[`phase-${row.phase}`, { open: isRowOpen(row.id) }]"
>
<button
class="member-head"
type="button"
:aria-expanded="isRowOpen(row.id)"
@click="toggleRow(row.id)"
>
<StatusDot class="row-dot" :status="row.phase" />
<Tooltip :text="row.name">
<span class="mname">{{ row.name }}</span>
</Tooltip>
<Tooltip v-if="row.activity" :text="row.activity">
<span class="mact">{{ row.activity }}</span>
</Tooltip>
<span class="mphase">{{ phaseLabel(row.phase) }}</span>
<Icon class="mcar" :name="isRowOpen(row.id) ? 'chevron-down' : 'chevron-right'" size="sm" />
</button>
<div class="member-line">
<button
class="member-head"
type="button"
:aria-expanded="isRowOpen(row.id)"
@click="toggleRow(row.id)"
>
<StatusDot class="row-dot" :status="row.phase" />
<Tooltip :text="row.name">
<span class="mname">{{ row.name }}</span>
</Tooltip>
<Tooltip v-if="row.activity" :text="row.activity">
<span class="mact">{{ row.activity }}</span>
</Tooltip>
<span class="mphase">{{ phaseLabel(row.phase) }}</span>
<Icon class="mcar" :name="isRowOpen(row.id) ? 'chevron-down' : 'chevron-right'" size="sm" />
</button>
<IconButton
v-if="cancelSubagent && row.canStop"
class="member-stop"
size="sm"
:label="t('tasks.stop')"
@click="stopSubagent(row.id)"
>
<Icon name="stop" size="sm" />
</IconButton>
</div>
<div v-show="isRowOpen(row.id)" class="member-body">{{ row.body }}</div>
</div>
</template>
Expand Down Expand Up @@ -395,11 +412,16 @@ function phaseLabel(phase: AppSubagentPhase): string {
.member:last-child {
border-bottom: none;
}
.member-line {
display: flex;
align-items: center;
}
.member-head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
flex: 1;
min-width: 0;
min-height: 32px;
padding: 0 11px;
border: none;
Expand Down Expand Up @@ -456,6 +478,9 @@ function phaseLabel(phase: AppSubagentPhase): string {
color: var(--color-text-faint);
flex: none;
}
.member-stop {
margin-right: var(--space-1);
}
.member-body {
padding: 4px 11px 10px 31px;
color: var(--color-text-muted);
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-web/src/lib/swarmCardRows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface SwarmCardRow {
activity: string;
phase: AppSubagentPhase;
body: string;
/** Only live, non-terminal members have an Agent id accepted by stop. */
canStop: boolean;
}

function lastNonEmptyLine(text: string | undefined): string {
Expand Down Expand Up @@ -53,6 +55,7 @@ function resultRow(sub: SwarmResultSubagent, index: number): SwarmCardRow {
activity: sub.body.split('\n')[0] ?? '',
phase: outcomeToPhase(sub.outcome),
body: sub.body,
canStop: false,
};
}

Expand Down Expand Up @@ -85,6 +88,7 @@ export function buildSwarmCardRows(members: SwarmMember[], result: SwarmResult |
activity: swarmMemberActivity(m),
phase: m.phase,
body: swarmMemberBody(m),
canStop: m.phase === 'queued' || m.phase === 'working' || m.phase === 'suspended',
}));
if (!result) return memberRows;

Expand Down
53 changes: 53 additions & 0 deletions apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,59 @@ describe('subagent streaming text', () => {
});
});

describe('subagent cancellation', () => {
it('projects an explicitly cancelled subagent as cancelled', () => {
const projector = createAgentProjector();
projector.project(
'subagent.spawned',
{ agentId: 'main', subagentId: 'agent-child', runInBackground: false },
's1',
);

const events = projector.project(
'subagent.failed',
{
agentId: 'main',
subagentId: 'agent-child',
error: 'Aborted by the user',
cancelled: true,
},
's1',
);

expect(events).toContainEqual(
expect.objectContaining({
type: 'taskCompleted',
taskId: 'agent-child',
status: 'cancelled',
}),
);
});

it('maps a killed task termination to cancelled', () => {
const projector = createAgentProjector();
const events = projector.project(
'task.terminated',
{
agentId: 'main',
info: {
taskId: 'task-1',
status: 'killed',
},
},
's1',
);

expect(events).toEqual([
expect.objectContaining({
type: 'taskCompleted',
taskId: 'task-1',
status: 'cancelled',
}),
]);
});
});

describe('agent error projection', () => {
it('drops a subagent error instead of surfacing it as a session warning', () => {
const projector = createAgentProjector();
Expand Down
4 changes: 3 additions & 1 deletion apps/kimi-web/test/swarm-card-rows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('buildSwarmCardRows', () => {
[member('a', '子任务 A', { text: 'streaming' })],
null,
);
expect(rows).toEqual([{ id: 'a', name: '子任务 A', activity: 'streaming', phase: 'working', body: 'streaming' }]);
expect(rows).toEqual([{ id: 'a', name: '子任务 A', activity: 'streaming', phase: 'working', body: 'streaming', canStop: true }]);
});

it('builds rows from result subagents when no members are present', () => {
Expand All @@ -77,6 +77,7 @@ describe('buildSwarmCardRows', () => {
);
expect(rows.map((r) => r.name)).toEqual(['A', 'B']);
expect(rows.map((r) => r.phase)).toEqual(['completed', 'failed']);
expect(rows.every((r) => r.canStop === false)).toBe(true);
});

it('appends result-only aborted not_started rows on top of live members', () => {
Expand All @@ -94,6 +95,7 @@ describe('buildSwarmCardRows', () => {
expect(rows.map((r) => r.id)).toEqual(['a1', 'a2', 'C']);
expect(rows[2]?.phase).toBe('failed');
expect(rows[2]?.body).toBe('C never started');
expect(rows[2]?.canStop).toBe(false);
});

it('does not duplicate a result row that a live member already covers', () => {
Expand Down
9 changes: 6 additions & 3 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,15 +562,18 @@ describe('useWorkspaceState — cancelTask', () => {
expect(state.tasksBySession['sess_1']?.[0]?.status).toBe('running');
});

it('marks the task cancelled on success', async () => {
it('sends the displayed Agent id and marks the subagent cancelled on success', async () => {
apiMock.cancelTask.mockResolvedValue({ cancelled: true });
const state = createState();
state.tasksBySession = { sess_1: [task('t_1', 'running')] };
state.tasksBySession = {
sess_1: [{ ...task('agent_1', 'running'), kind: 'subagent' }],
};
const deps = createDeps();
const ws = useWorkspaceState(state, deps);

await ws.cancelTask('t_1');
await ws.cancelTask('agent_1');

expect(apiMock.cancelTask).toHaveBeenCalledWith('sess_1', 'agent_1');
expect(state.tasksBySession['sess_1']?.[0]?.status).toBe('cancelled');
expect(deps.pushOperationFailure).not.toHaveBeenCalled();
});
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core-v2/src/agent/task/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
* detached work. Task execution adapters implement the generic `AgentTask`
* contract from this domain's type module; this service owns registration,
* output retention, persistence, detach/stop/wait, and terminal notifications.
* Bound at Agent scope.
* Agent tasks can also be resolved by their stable Agent id so protocol edges
* do not have to confuse that identity with the generated task id. Bound at
* Agent scope.
*/

import { createDecorator } from '#/_base/di/instantiation';
Expand Down Expand Up @@ -108,6 +110,7 @@ export interface IAgentTaskService {
/** @deprecated Use `taskService.run()` + `track()` instead. */
registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string;
getTask(taskId: string): AgentTaskInfo | undefined;
getAgentTask(agentId: string): AgentTaskInfo | undefined;
list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[];
persistOutput(taskId: string): void;
getOutputSnapshot(
Expand Down
32 changes: 30 additions & 2 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import type { ContentPart } from '#/app/llmProtocol/message';

import { Disposable } from '#/_base/di/lifecycle';
import { abortable } from '#/_base/utils/abort';
import { abortable, userCancellationReason } from '#/_base/utils/abort';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event/eventBus';
import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types';
Expand Down Expand Up @@ -455,6 +455,17 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
return entry === undefined ? this.ghosts.get(taskId) : this.toInfo(entry);
}

getAgentTask(agentId: string): AgentTaskInfo | undefined {
let found: AgentTaskInfo | undefined;
for (const entry of this.tasks.values()) {
found = newerAgentTaskForAgent(found, this.toInfo(entry), agentId);
}
for (const ghost of this.ghosts.values()) {
found = newerAgentTaskForAgent(found, ghost, agentId);
}
return found;
}

list(activeOnly = true, limit?: number): readonly AgentTaskInfo[] {
const result: AgentTaskInfo[] = [];
for (const entry of this.tasks.values()) {
Expand Down Expand Up @@ -648,7 +659,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
const normalized = normalizeReason(reason);
return this.terminateWithGrace(entry, {
stopReason: normalized,
abortReason: normalized,
abortReason: userCancellationReason(),
finalStatus: 'killed',
});
}
Expand Down Expand Up @@ -1211,6 +1222,23 @@ function newerRestoredTask(
return loaded;
}

function newerAgentTaskForAgent(
current: AgentTaskInfo | undefined,
candidate: AgentTaskInfo,
agentId: string,
): AgentTaskInfo | undefined {
if (candidate.kind !== 'agent' || candidate.agentId !== agentId) return current;
if (current === undefined) return candidate;

const currentActive = !isAgentTaskTerminal(current.status);
const candidateActive = !isAgentTaskTerminal(candidate.status);
if (currentActive !== candidateActive) return candidateActive ? candidate : current;

const currentTimestamp = current.endedAt ?? current.startedAt;
const candidateTimestamp = candidate.endedAt ?? candidate.startedAt;
return candidateTimestamp >= currentTimestamp ? candidate : current;
}

type TaskNotificationOrigin = Pick<TaskOrigin, 'taskId' | 'status' | 'notificationId'>;

function isTaskOrigin(origin: unknown): origin is TaskNotificationOrigin {
Expand Down
Loading
Loading