Skip to content

Commit 9fee463

Browse files
committed
fix: tower mission context, review reconciliation and spawn metadata
Tower missions take an optional context field that carries the user's own sentences verbatim into the mission file and every briefing, so worker and reviewer see the intent and not only a paraphrase. Reviewer briefings now include the mission text and the author's own report with an intent-first checklist, and workers escalate substantive ambiguity to the tower instead of guessing. Tower spawns honour the configured subagent timeout instead of a fixed two hours, and the /tasks list shows each background agent's model.
1 parent 7433f25 commit 9fee463

19 files changed

Lines changed: 403 additions & 40 deletions

File tree

apps/pythinker-code/src/tui/components/dialogs/tasks-browser.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,17 @@ import {
2222
visibleWidth,
2323
type Focusable,
2424
} from '@pymodel/pi-tui';
25-
import type { BackgroundTaskInfo, BackgroundTaskStatus } from '@pymodel/pythinker-code-sdk';
25+
import type {
26+
BackgroundTaskInfo,
27+
BackgroundTaskStatus,
28+
ModelAlias,
29+
} from '@pymodel/pythinker-code-sdk';
2630

2731
import { SELECT_POINTER } from '@/tui/constant/symbols';
2832
import { currentTheme } from '#/tui/theme';
2933
import { printableChar } from '@/tui/utils/printable-key';
3034
import { sanitizeShellOutput } from '#/tui/utils/shell-output';
35+
import { modelDisplayName } from './model-selector';
3136

3237
const ELLIPSIS = '…';
3338

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

455463
this.adjustScroll(innerHeight);
456-
const start = this.listScroll;
457-
const window = this.sortedVisible.slice(start, start + innerHeight);
458464

459465
const innerWidth = width - 2;
460-
const lines: string[] = [];
461-
for (const [vi, task] of window.entries()) {
462-
const index = start + vi;
463-
lines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
466+
const allLines: string[] = [];
467+
for (const [index, task] of this.sortedVisible.entries()) {
468+
allLines.push(this.renderListRow(task, index === this.selectedIndex, innerWidth));
469+
const modelText = this.agentModelText(task);
470+
if (modelText !== undefined) {
471+
allLines.push(this.renderModelRow(modelText, innerWidth));
472+
}
464473
}
474+
const lines = allLines.slice(this.listScroll, this.listScroll + innerHeight);
465475
while (lines.length < innerHeight) lines.push('');
466476

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

512+
/** Secondary line under an agent task's row: the model it runs on, resolved
513+
* through the model catalog like the other subagent surfaces. */
514+
private agentModelText(task: BackgroundTaskInfo): string | undefined {
515+
if (task.kind !== 'agent' || task.model === undefined) return undefined;
516+
const name = modelDisplayName(task.model, this.props.availableModels[task.model]);
517+
return name.length === 0 ? undefined : name;
518+
}
519+
520+
private renderModelRow(text: string, innerWidth: number): string {
521+
const indent = ' ';
522+
const clipped = truncateToWidth(text, Math.max(0, innerWidth - indent.length), ELLIPSIS);
523+
return indent + currentTheme.fg('textMuted', clipped);
524+
}
525+
526+
// Agent tasks with a bound model take two lines (row + model line), so
527+
// scrolling is tracked in rendered lines rather than task indices.
528+
private taskLineStarts(): { starts: number[]; total: number } {
529+
const starts: number[] = [];
530+
let total = 0;
531+
for (const task of this.sortedVisible) {
532+
starts.push(total);
533+
total += this.agentModelText(task) === undefined ? 1 : 2;
534+
}
535+
return { starts, total };
536+
}
537+
502538
private adjustScroll(visibleRows: number): void {
503539
if (visibleRows <= 0) {
504540
this.listScroll = 0;
505541
return;
506542
}
507-
if (this.selectedIndex < this.listScroll) {
508-
this.listScroll = this.selectedIndex;
509-
} else if (this.selectedIndex >= this.listScroll + visibleRows) {
510-
this.listScroll = this.selectedIndex - visibleRows + 1;
543+
const { starts, total } = this.taskLineStarts();
544+
const selectedStart = starts[this.selectedIndex] ?? 0;
545+
const selectedEnd = (starts[this.selectedIndex + 1] ?? total) - 1;
546+
if (selectedStart < this.listScroll) {
547+
this.listScroll = selectedStart;
548+
} else if (selectedEnd >= this.listScroll + visibleRows) {
549+
this.listScroll = selectedEnd - visibleRows + 1;
511550
}
512-
const maxScroll = Math.max(0, this.sortedVisible.length - visibleRows);
551+
const maxScroll = Math.max(0, total - visibleRows);
513552
if (this.listScroll < 0) this.listScroll = 0;
514553
if (this.listScroll > maxScroll) this.listScroll = maxScroll;
515554
}
@@ -560,7 +599,7 @@ export class TasksBrowserApp extends Container implements Focusable {
560599
lines.push(`${label('Agent type:')}${value(task.subagentType)}`);
561600
}
562601
if (task.kind === 'agent' && task.model !== undefined) {
563-
lines.push(`${label('Model:')}${value(task.model)}`);
602+
lines.push(`${label('Model:')}${value(this.agentModelText(task) ?? task.model)}`);
564603
}
565604
if (task.kind === 'agent' && task.thinkingEffort !== undefined) {
566605
lines.push(`${label('Effort:')}${value(task.thinkingEffort)}`);

apps/pythinker-code/src/tui/controllers/tasks-browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { TaskOutputViewer } from '../components/dialogs/task-output-viewer';
66
import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser';
77
import type { Theme } from '#/tui/theme';
88
import type { CustomEditor } from '../components/editor/custom-editor';
9+
import type { AppState } from '../types';
910
import {
1011
beginScreenTakeover,
1112
endScreenTakeover,
@@ -21,6 +22,7 @@ export interface TasksBrowserHost {
2122
readonly terminal: ProcessTerminal;
2223
readonly ui: TUI;
2324
readonly editor: CustomEditor;
25+
readonly appState: Pick<AppState, 'availableModels'>;
2426
};
2527
readonly backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>;
2628
readonly sessionEventHandler: SessionEventHandler;
@@ -86,6 +88,7 @@ export class TasksBrowserController {
8688
tailOutput: undefined,
8789
tailLoading: false,
8890
flashMessage: undefined,
91+
availableModels: state.appState.availableModels,
8992
...this.buildCallbacks(),
9093
},
9194
state.terminal,
@@ -250,6 +253,7 @@ export class TasksBrowserController {
250253
tailOutput: browser.tailOutput,
251254
tailLoading: browser.tailLoading,
252255
flashMessage: browser.flashMessage,
256+
availableModels: this.host.state.appState.availableModels,
253257
...this.buildCallbacks(),
254258
});
255259
this.host.state.ui.requestRender();

apps/pythinker-code/test/tui/tasks-browser.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp
6868
tailOutput: undefined,
6969
tailLoading: false,
7070
flashMessage: undefined,
71+
availableModels: {},
7172
onSelect: vi.fn(),
7273
onToggleFilter: vi.fn(),
7374
onRefresh: vi.fn(),
@@ -79,6 +80,14 @@ function makeProps(overrides: Partial<TasksBrowserProps> = {}): TasksBrowserProp
7980
} as TasksBrowserProps;
8081
}
8182

83+
const CATALOG = {
84+
'k2-cheap': {
85+
provider: 'acme',
86+
model: 'acme-fast',
87+
displayName: 'Acme Fast',
88+
},
89+
} as never;
90+
8291
function makeApp(
8392
props: Partial<TasksBrowserProps> = {},
8493
rows = 30,
@@ -229,6 +238,113 @@ describe('TasksBrowserApp — full-screen rendering', () => {
229238
expect(out).toContain('low');
230239
});
231240

241+
it('shows the agent model on a secondary line under the task row', () => {
242+
const app = makeApp({
243+
tasks: [
244+
task({
245+
taskId: 'agent-aaaaaaaa',
246+
kind: 'agent',
247+
status: 'running',
248+
description: 'explore project',
249+
agentId: 'agent-1',
250+
model: 'k2-cheap',
251+
}),
252+
task({ taskId: 'bash-bbbbbbbb', status: 'running' }),
253+
],
254+
selectedTaskId: 'agent-aaaaaaaa',
255+
availableModels: CATALOG,
256+
});
257+
const lines = app.render(120).map(strip);
258+
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
259+
expect(rowIndex).toBeGreaterThanOrEqual(0);
260+
expect(lines[rowIndex + 1]).toContain('Acme Fast');
261+
expect(lines[rowIndex + 2]).toContain('bash-bbbbbbbb');
262+
});
263+
264+
it('falls back to the raw model alias when the catalog has no entry', () => {
265+
const app = makeApp({
266+
tasks: [
267+
task({
268+
taskId: 'agent-aaaaaaaa',
269+
kind: 'agent',
270+
status: 'running',
271+
agentId: 'agent-1',
272+
model: 'acme/large-256k',
273+
}),
274+
],
275+
selectedTaskId: 'agent-aaaaaaaa',
276+
});
277+
const lines = app.render(120).map(strip);
278+
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
279+
expect(rowIndex).toBeGreaterThanOrEqual(0);
280+
expect(lines[rowIndex + 1]).toContain('acme/large-256k');
281+
});
282+
283+
it('resolves the Detail pane model through the catalog', () => {
284+
const out = strip(
285+
makeApp({
286+
tasks: [
287+
task({
288+
taskId: 'agent-aaaaaaaa',
289+
kind: 'agent',
290+
status: 'running',
291+
agentId: 'agent-1',
292+
model: 'k2-cheap',
293+
}),
294+
],
295+
selectedTaskId: 'agent-aaaaaaaa',
296+
availableModels: CATALOG,
297+
})
298+
.render(120)
299+
.join('\n'),
300+
);
301+
expect(out).toContain('Model:');
302+
expect(out).toContain('Acme Fast');
303+
});
304+
305+
it('keeps agent tasks without a model on a single line', () => {
306+
const app = makeApp({
307+
tasks: [
308+
task({
309+
taskId: 'agent-aaaaaaaa',
310+
kind: 'agent',
311+
status: 'running',
312+
agentId: 'agent-1',
313+
startedAt: 1,
314+
}),
315+
task({ taskId: 'bash-bbbbbbbb', status: 'running', startedAt: 2 }),
316+
],
317+
selectedTaskId: 'agent-aaaaaaaa',
318+
});
319+
const lines = app.render(120).map(strip);
320+
const rowIndex = lines.findIndex((line) => line.includes('agent-aaaaaaaa'));
321+
expect(rowIndex).toBeGreaterThanOrEqual(0);
322+
expect(lines[rowIndex + 1]).toContain('bash-bbbbbbbb');
323+
});
324+
325+
it('keeps the selected agent row and its model line visible when scrolling', () => {
326+
const tasks = Array.from({ length: 12 }, (_, i) =>
327+
task({
328+
taskId: `agent-${String(i).padStart(8, '0')}`,
329+
kind: 'agent',
330+
status: 'running',
331+
description: `task ${String(i)}`,
332+
agentId: `agent-${String(i)}`,
333+
model: 'k2-cheap',
334+
startedAt: i,
335+
} as Partial<BackgroundTaskInfo>),
336+
);
337+
const app = new TasksBrowserApp(
338+
makeProps({ tasks, selectedTaskId: 'agent-00000011', availableModels: CATALOG }),
339+
fakeTerminal(12, 120),
340+
);
341+
const lines = app.render(120).map(strip);
342+
expect(lines.length).toBe(12);
343+
const rowIndex = lines.findIndex((line) => line.includes('agent-00000011'));
344+
expect(rowIndex).toBeGreaterThanOrEqual(0);
345+
expect(lines[rowIndex + 1]).toContain('Acme Fast');
346+
});
347+
232348
it('renders tail output in the Preview Output pane', () => {
233349
const out = strip(
234350
makeApp({
@@ -575,6 +691,7 @@ describe('TasksBrowserController — opening an agent task', () => {
575691
terminal: fakeTerminal(30),
576692
ui,
577693
editor: {},
694+
appState: { availableModels: {} },
578695
};
579696
const host = {
580697
state,

packages/agent-core-v2/src/agent/tools/agent/agent.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ Writing the prompt:
99
Usage notes:
1010
- 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.
1111
- 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.
12-
- Subagents use a fixed 2-hour timeout. If one times out, resume the same agent instead of starting over.
1312

1413
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.
1514

packages/agent-core-v2/src/features/tower/injection/tower-mode-full-reminder.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ Working principles:
2424
## Tower workflow
2525

2626
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.
27-
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.
27+
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.
2828
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 bind the configured secondary model when the secondary-model experiment is on (they inherit your model otherwise); 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`.
2929
4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act:
30-
- 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.
30+
- 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.
3131
- 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).
3232
- Blocker → answer or reassign if you can; if it genuinely needs the human, inform them and keep the rest moving.
3333
- Finding → triage: assign to a mission, plan a new one, or backlog — the disposition is your call; tell the human.

packages/agent-core-v2/src/features/tower/protocol/store.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export interface TowerPlanInput {
7272
readonly title: string;
7373
readonly scope: readonly string[];
7474
readonly tasks?: readonly string[];
75+
readonly context?: string;
7576
readonly deps?: readonly string[];
7677
readonly kind?: TowerMissionKind;
7778
}
@@ -423,6 +424,10 @@ export class TowerStore {
423424
worktree: `wt-${n}`,
424425
deps: item.deps ?? [],
425426
status: 'planned',
427+
context:
428+
item.context !== undefined && item.context.trim().length > 0
429+
? item.context.trim()
430+
: undefined,
426431
tasks: (item.tasks ?? []).map((text) => ({ text, done: false })),
427432
notes: [],
428433
blockers: [],
@@ -1076,6 +1081,9 @@ export class TowerStore {
10761081
'| ------ | -------- | ------ | ----- | ----- |',
10771082
`| ${mission.branch} | ${mission.worktree} | ${STATUS_EMOJI[mission.status]} | ${mission.scope.join(', ')} | ${mission.owner ?? '—'} |`,
10781083
'',
1084+
...(mission.context !== undefined
1085+
? ['## Context — the user\'s own words, verbatim', '', mission.context, '']
1086+
: []),
10791087
'## Tasks',
10801088
...(mission.tasks.length > 0
10811089
? mission.tasks.map((t) => `- [${t.done ? 'x' : ' '}] ${t.text}`)

packages/agent-core-v2/src/features/tower/protocol/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export interface TowerMission {
4747
readonly deps: readonly string[];
4848
status: TowerMissionStatus;
4949
owner?: string;
50+
context?: string;
5051
tasks: TowerMissionTask[];
5152
notes: string[];
5253
blockers: string[];
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
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).
22

3+
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.
4+
35
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).

packages/agent-core-v2/src/features/tower/tools/plan/plan.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ export const TowerPlanToolInputSchema = z
1919
tasks: z
2020
.array(z.string())
2121
.optional()
22-
.describe('Checklist the worker will tick off via TowerMission task_done'),
22+
.describe(
23+
'Checklist the worker will tick off via TowerMission task_done — write each task as a verifiable item a reviewer can map to the diff',
24+
),
25+
context: z
26+
.string()
27+
.optional()
28+
.describe(
29+
"The user's own key sentences about this mission, copied verbatim — the tower's paraphrase supplements them, never replaces them. Fill this whenever the requirement could be misread; never paste the full conversation history.",
30+
),
2331
deps: z
2432
.array(z.string())
2533
.optional()

0 commit comments

Comments
 (0)