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: 5 additions & 0 deletions .changeset/tasks-list-agent-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@moonshot-ai/kimi-code': patch
---

The /tasks panel now shows each background agent's model under its task row.
5 changes: 5 additions & 0 deletions .changeset/tower-mode-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Tower worker and reviewer briefings now carry the mission's user-verbatim context, and reviewers also see the full mission text and the worker's review request. Tower worker and reviewer agent timeouts now follow the subagent timeout setting (`[subagent] timeout_ms` or `KIMI_SUBAGENT_TIMEOUT_MS`), still defaulting to 2 hours. Fix the /tasks list not showing the model for tower-spawned worker and reviewer agents.
65 changes: 52 additions & 13 deletions apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ import {
visibleWidth,
type Focusable,
} from '@moonshot-ai/pi-tui';
import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@moonshot-ai/kimi-code-sdk';
import type {
BackgroundTaskInfo,
BackgroundTaskStatus,
ModelAlias,
} from '@moonshot-ai/kimi-code-sdk';

import { SELECT_POINTER } from '@/tui/constant/symbols';
import { currentTheme } from '#/tui/theme';
import { printableChar } from '@/tui/utils/printable-key';
import { sanitizeShellOutput } from '#/tui/utils/shell-output';
import { modelDisplayName } from './model-selector';

const ELLIPSIS = '…';

Expand All @@ -40,6 +45,9 @@ export interface TasksBrowserProps {
readonly tailOutput: string | undefined;
readonly tailLoading: boolean;
readonly flashMessage: string | undefined;
/** Model catalog from the app config, used to resolve task model aliases
* to display names (same mapping as the other subagent surfaces). */
readonly availableModels: Record<string, ModelAlias>;
readonly onSelect: (taskId: string) => void;
readonly onToggleFilter: () => void;
readonly onRefresh: () => void;
Expand Down Expand Up @@ -453,15 +461,17 @@ export class TasksBrowserApp extends Container implements Focusable {
}

this.adjustScroll(innerHeight);
const start = this.listScroll;
const window = this.sortedVisible.slice(start, start + innerHeight);

const innerWidth = width - 2;
const lines: string[] = [];
for (const [vi, task] of window.entries()) {
const index = start + vi;
lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
const allLines: string[] = [];
for (const [index, task] of this.sortedVisible.entries()) {
allLines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
const modelText = this.agentModelText(task);
if (modelText !== undefined) {
allLines.push(this.renderModelRow(modelText, innerWidth));
}
}
const lines = allLines.slice(this.listScroll, this.listScroll + innerHeight);
while (lines.length < innerHeight) lines.push('');

return this.renderFrame(title, lines, width, height);
Expand Down Expand Up @@ -499,17 +509,46 @@ export class TasksBrowserApp extends Container implements Focusable {
return fitExactly(`${prefix} ${currentTheme.fg('text', desc)}`, innerWidth);
}

/** Secondary line under an agent task's row: the model it runs on, resolved
* through the model catalog like the other subagent surfaces. */
private agentModelText(task: BackgroundTaskInfo): string | undefined {
if (task.kind !== 'agent' || task.model === undefined) return undefined;
const name = modelDisplayName(task.model, this.props.availableModels[task.model]);
return name.length === 0 ? undefined : name;
}

private renderModelRow(text: string, innerWidth: number): string {
const indent = ' ';
const clipped = truncateToWidth(text, Math.max(0, innerWidth - indent.length), ELLIPSIS);
return indent + currentTheme.fg('textMuted', clipped);
}

// Agent tasks with a bound model take two lines (row + model line), so
// scrolling is tracked in rendered lines rather than task indices.
private taskLineStarts(): { starts: number[]; total: number } {
const starts: number[] = [];
let total = 0;
for (const task of this.sortedVisible) {
starts.push(total);
total += this.agentModelText(task) === undefined ? 1 : 2;
}
return { starts, total };
}

private adjustScroll(visibleRows: number): void {
if (visibleRows <= 0) {
this.listScroll = 0;
return;
}
if (this.selectedIndex < this.listScroll) {
this.listScroll = this.selectedIndex;
} else if (this.selectedIndex >= this.listScroll + visibleRows) {
this.listScroll = this.selectedIndex - visibleRows + 1;
const { starts, total } = this.taskLineStarts();
const selectedStart = starts[this.selectedIndex] ?? 0;
const selectedEnd = (starts[this.selectedIndex + 1] ?? total) - 1;
if (selectedStart < this.listScroll) {
this.listScroll = selectedStart;
} else if (selectedEnd >= this.listScroll + visibleRows) {
this.listScroll = selectedEnd - visibleRows + 1;
}
const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows);
const maxScroll = Math.max(0, total - visibleRows);
if (this.listScroll < 0) this.listScroll = 0;
if (this.listScroll > maxScroll) this.listScroll = maxScroll;
}
Expand Down Expand Up @@ -560,7 +599,7 @@ export class TasksBrowserApp extends Container implements Focusable {
lines.push(`${label('Agent type:')}${value(task.subagentType)}`);
}
if (task.kind === 'agent' && task.model !== undefined) {
lines.push(`${label('Model:')}${value(task.model)}`);
lines.push(`${label('Model:')}${value(this.agentModelText(task) ?? task.model)}`);
}
if (task.kind === 'agent' && task.thinkingEffort !== undefined) {
lines.push(`${label('Effort:')}${value(task.thinkingEffort)}`);
Expand Down
4 changes: 4 additions & 0 deletions apps/kimi-code/src/tui/controllers/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { TaskOutputViewer } from '../components/dialogs/task-output-viewer';
import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser';
import type { Theme } from '#/tui/theme';
import type { CustomEditor } from '../components/editor/custom-editor';
import type { AppState } from '../types';
import {
beginScreenTakeover,
endScreenTakeover,
Expand All @@ -21,6 +22,7 @@ export interface TasksBrowserHost {
readonly terminal: ProcessTerminal;
readonly ui: TUI;
readonly editor: CustomEditor;
readonly appState: Pick<AppState, 'availableModels'>;
};
readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>;
readonly sessionEventHandler: SessionEventHandler;
Expand Down Expand Up @@ -86,6 +88,7 @@ export class TasksBrowserController {
tailOutput: undefined,
tailLoading: false,
flashMessage: undefined,
availableModels: state.appState.availableModels,
...this.buildCallbacks(),
},
state.terminal,
Expand Down Expand Up @@ -250,6 +253,7 @@ export class TasksBrowserController {
tailOutput: browser.tailOutput,
tailLoading: browser.tailLoading,
flashMessage: browser.flashMessage,
availableModels: this.host.state.appState.availableModels,
...this.buildCallbacks(),
});
this.host.state.ui.requestRender();
Expand Down
117 changes: 117 additions & 0 deletions apps/kimi-code/test/tui/tasks-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp
tailOutput: undefined,
tailLoading: false,
flashMessage: undefined,
availableModels: {},
onSelect: vi.fn(),
onToggleFilter: vi.fn(),
onRefresh: vi.fn(),
Expand All @@ -79,6 +80,14 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp
} as TasksBrowserProps;
}

const CATALOG = {
'k2-cheap': {
provider: 'managed:kimi-code',
model: 'kimi-k2-cheap',
displayName: 'Kimi K2 Cheap',
},
} as never;

function makeApp(
props: Partial<TasksBrowserProps> = {},
rows = 30,
Expand Down Expand Up @@ -229,6 +238,113 @@ describe('TasksBrowserApp — full-screen rendering', () => {
expect(out).toContain('low');
});

it('shows the agent model on a secondary line under the task row', () => {
const app = makeApp({
tasks: [
task({
taskId: 'agent-aaaaaaaa',
kind: 'agent',
status: 'running',
description: 'explore project',
agentId: 'agent-1',
model: 'k2-cheap',
}),
task({ taskId: 'bash-bbbbbbbb', status: 'running' }),
],
selectedTaskId: 'agent-aaaaaaaa',
availableModels: CATALOG,
});
const lines = app.render(120).map(strip);
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
expect(rowIndex).toBeGreaterThanOrEqual(0);
expect(lines[rowIndex + 1]).toContain('Kimi K2 Cheap');
expect(lines[rowIndex + 2]).toContain('bash-bbbbbbbb');
});

it('falls back to the raw model alias when the catalog has no entry', () => {
const app = makeApp({
tasks: [
task({
taskId: 'agent-aaaaaaaa',
kind: 'agent',
status: 'running',
agentId: 'agent-1',
model: 'kimi-code/k3-256k',
}),
],
selectedTaskId: 'agent-aaaaaaaa',
});
const lines = app.render(120).map(strip);
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
expect(rowIndex).toBeGreaterThanOrEqual(0);
expect(lines[rowIndex + 1]).toContain('kimi-code/k3-256k');
});

it('resolves the Detail pane model through the catalog', () => {
const out = strip(
makeApp({
tasks: [
task({
taskId: 'agent-aaaaaaaa',
kind: 'agent',
status: 'running',
agentId: 'agent-1',
model: 'k2-cheap',
}),
],
selectedTaskId: 'agent-aaaaaaaa',
availableModels: CATALOG,
})
.render(120)
.join('\n'),
);
expect(out).toContain('Model:');
expect(out).toContain('Kimi K2 Cheap');
});

it('keeps agent tasks without a model on a single line', () => {
const app = makeApp({
tasks: [
task({
taskId: 'agent-aaaaaaaa',
kind: 'agent',
status: 'running',
agentId: 'agent-1',
startedAt: 1,
}),
task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }),
],
selectedTaskId: 'agent-aaaaaaaa',
});
const lines = app.render(120).map(strip);
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
expect(rowIndex).toBeGreaterThanOrEqual(0);
expect(lines[rowIndex + 1]).toContain('bash-bbbbbbbb');
});

it('keeps the selected agent row and its model line visible when scrolling', () => {
const tasks = Array.from({ length: 12 }, (_, i) =>
task({
taskId: `agent-${String(i).padStart(8, '0')}`,
kind: 'agent',
status: 'running',
description: `task ${String(i)}`,
agentId: `agent-${String(i)}`,
model: 'k2-cheap',
startedAt: i,
} as Partial<BackgroundTaskInfo>),
);
const app = new TasksBrowserApp(
makeProps({ tasks, selectedTaskId: 'agent-00000011', availableModels: CATALOG }),
fakeTerminal(12, 120),
);
const lines = app.render(120).map(strip);
expect(lines.length).toBe(12);
const rowIndex = lines.findIndex((line) => line.includes('agent-00000011'));
expect(rowIndex).toBeGreaterThanOrEqual(0);
expect(lines[rowIndex + 1]).toContain('Kimi K2 Cheap');
});

it('renders tail output in the Preview Output pane', () => {
const out = strip(
makeApp({
Expand Down Expand Up @@ -575,6 +691,7 @@ describe('TasksBrowserController — opening an agent task', () => {
terminal: fakeTerminal(30),
ui,
editor: {},
appState: { availableModels: {} },
};
const host = {
state,
Expand Down
1 change: 0 additions & 1 deletion packages/agent-core-v2/src/agent/tools/agent/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ Writing the prompt:
Usage notes:
- When the task continues earlier work a subagent already did, prefer resuming that agent (pass its `resume` id) over spawning a fresh instance — the resumed agent keeps its prior context.
- A subagent's result is only visible to you, not to the user. When the user needs to see what a subagent produced, summarize the relevant parts yourself in your own reply.
- Subagents use a fixed 2-hour timeout. If one times out, resume the same agent instead of starting over.

When NOT to use Agent: skip delegation for trivial work you can do directly — reading a file whose path you already know, searching a small known set of files, or any task that takes only a step or two. Delegation has a context-handoff cost; it pays off only when the task is substantial enough to outweigh it.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ Working principles:
## Tower workflow

1. **Init** — `TowerInit`. It creates `.tower/` and records the base branch — when the human enabled tower mode with `/tower <base>`, the workspace and base branch are already set up, so `TowerInit` just confirms them. Workers and reviewers never prompt for tool approvals — they are pinned to the auto permission mode at spawn, whatever the session's mode. Your own orchestration calls still follow the session mode, so if it would interrupt you with constant prompts, tell the human once that a more autonomous mode fits tower better — then proceed regardless. When `TowerInit` reports carried-over open missions from a previous session, settle them **before planning**: continue the ones that belong to the current objective with fresh workers, and abandon the unrelated ones (`TowerMission status=abandoned`) — missions that are neither merged nor abandoned keep their scopes reserved, so `TowerPlan` rejects any new mission overlapping them.
2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate.
2. **Plan** — break the objective into 2–4 missions and call `TowerPlan` with each mission's title, **disjoint** scope globs (picomatch: `**` crosses directories), tasks, and dependencies. Write tasks as **verifiable** items a reviewer can map to the diff, and when the human's own words carry intent your paraphrase could lose, copy the key sentences into the mission's `context` **verbatim** — when in doubt, include it. `context` supplements your paraphrase (never replaces it, never holds the full conversation history) and is the one channel that carries the human's voice to both worker and reviewer. Mark read-only investigation missions `kind: "survey"`: a survey's scope is informational (it reserves nothing, so surveys and builds may overlap the same paths), the worker must not change code, and it closes with a zero-diff `TowerMerge` — no reviewer needed. Shared files (lockfiles, central configs) belong to exactly one build mission or to your own integration work. Post the plan to the human in one compact message and launch immediately — their words are plan changes, never a gate.
3. **Spawn** — one `TowerSpawn` per mission (`kind: "worker"`, background, code-built briefing), and **spawn every dependency-unblocked mission right away**: fire the `TowerSpawn` calls back to back, never trickle them out one at a time and never wait for one worker before launching the next — the fleet exists to run in parallel. The tool refuses duplicate names — resume the existing agent with the `Agent` tool instead. Workers commit on their branch; their completion wakes you. Once the batch is running, **end your turn**: completions and inbox traffic arrive as notifications, so never poll `TowerInbox`/`TowerStatus` in a loop and never sit synchronously waiting on a worker. Workers use the configured secondary model when `[secondary_model]` provides one; otherwise they inherit your model. Reviewers always bind your primary model — review quality is not where you save. The resolved model is shown in the spawn output and the `spawn` line of `activity.log`.
4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act:
- Review request → `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch). Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands.
- Review request → first reconcile the worker's report against the mission tasks **item by item** (a silently dropped task means the mission is not done — send it back), then `TowerSpawn` a reviewer (`kind: "reviewer"`, `review_target` the branch) — the briefing hands the reviewer the mission text and the worker's report, so the review verifies intent, not only code health. Do not review mission code yourself. Survey missions skip review — close them with `TowerMerge` once their summary lands.
- Review verdict not clean → resume the author (Agent tool) pointing at the review file; the author fixes, pushes, and requests re-review. Round cap: at 5 rounds, or when two consecutive rounds report the same findings, stop the loop, inform the human, and redirect (reassign, split, descope).
- Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving.
- Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human.
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-core-v2/src/features/tower/protocol/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export interface TowerPlanInput {
readonly title: string;
readonly scope: readonly string[];
readonly tasks?: readonly string[];
readonly context?: string;
readonly deps?: readonly string[];
readonly kind?: TowerMissionKind;
}
Expand Down Expand Up @@ -452,6 +453,10 @@ export class TowerStore {
worktree: `wt-${n}`,
deps: item.deps ?? [],
status: 'planned',
context:
item.context !== undefined && item.context.trim().length > 0
? item.context.trim()
: undefined,
tasks: (item.tasks ?? []).map((text) => ({ text, done: false })),
notes: [],
blockers: [],
Expand Down Expand Up @@ -1105,6 +1110,9 @@ export class TowerStore {
'| ------ | -------- | ------ | ----- | ----- |',
`| ${mission.branch} | ${mission.worktree} | ${STATUS_EMOJI[mission.status]} | ${mission.scope.join(', ')} | ${mission.owner ?? '—'} |`,
'',
...(mission.context !== undefined
? ['## Context — the user\'s own words, verbatim', '', mission.context, '']
: []),
'## Tasks',
...(mission.tasks.length > 0
? mission.tasks.map((t) => `- [${t.done ? 'x' : ' '}] ${t.text}`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export interface TowerMission {
readonly deps: readonly string[];
status: TowerMissionStatus;
owner?: string;
context?: string;
tasks: TowerMissionTask[];
notes: string[];
blockers: string[];
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-core-v2/src/features/tower/tools/plan/plan.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Split the tower goal into missions. Each mission gets an id (M1, M2, …), a branch (feat/<slug>), and an isolated git worktree (.tower/worktrees/wt-N).

Write tasks as verifiable check items — the worker ticks them off, the completion report reconciles against them item by item, and the reviewer maps every one to the diff. When the user's own words carry intent your paraphrase could lose, copy the key sentences into `context` verbatim (when in doubt, include it): context supplements your paraphrase, never replaces it, travels with the mission into the worker and reviewer briefings, and is never the full conversation history.

Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), and deps must reference existing mission ids. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first).
Loading
Loading