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/tower-ascii-titles-and-token-usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pymodel/pythinker-code": patch
---

Tower mode: mission titles must be printable ASCII, tower messages, findings, and reviews record the sender's token usage, and task cards show the mission id.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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. 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.
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. Titles must be **printable ASCII English** — any non-ASCII character is rejected and forces a re-plan. Give every title a unique identifier word (a business code, a module name, a ticket id). 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, always in the background (`run_in_background=true`). 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 → 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.
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-core-v2/src/features/tower/protocol/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export function slugify(text: string, maxLength = 60): string {
return slug.length > 0 ? slug : 'item';
}

export function hasNonAsciiCharacters(text: string): boolean {
return /[^\u0020-\u007E]/.test(text);
}

export function targetSlug(target: string): string {
const cleaned = target.trim().replace(/^#/, 'pr');
return slugify(cleaned.replaceAll(/[/#]+/g, '-'));
Expand Down
37 changes: 34 additions & 3 deletions packages/agent-core-v2/src/features/tower/protocol/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
isReservedTowerAgentName,
dateDash,
findingFileName,
hasNonAsciiCharacters,
inboxFileName,
missionFileName,
reviewFileName,
Expand Down Expand Up @@ -91,6 +92,7 @@ export interface TowerSendInput {
readonly scope?: string;
readonly action?: string;
readonly consentRef?: string;
readonly token_count?: number;
}

export interface TowerFindingInput {
Expand All @@ -101,6 +103,7 @@ export interface TowerFindingInput {
readonly location?: string;
readonly details: string;
readonly suggestedFix: string;
readonly token_count?: number;
}

export interface TowerReviewInput {
Expand All @@ -110,6 +113,7 @@ export interface TowerReviewInput {
readonly findings: string;
readonly checks?: readonly string[];
readonly decision: string;
readonly token_count?: number;
}

export interface TowerMissionPatch {
Expand Down Expand Up @@ -497,6 +501,14 @@ export class TowerStore {
if (input.length === 0) {
throw new TowerProtocolError('TowerPlan needs at least one mission');
}
for (const item of input) {
if (hasNonAsciiCharacters(item.title)) {
const offending = /[^\u0020-\u007E]/.exec(item.title)![0];
throw new TowerProtocolError(
`mission title "${item.title}" contains non-ASCII characters (first: "${offending}") — titles must be printable ASCII English: the title becomes the branch/worktree slug, and non-ASCII text slugs to a generic word like "item" that collides across missions; rewrite the title in English with a unique identifier word (e.g. a business code like B010100) and plan again`,
);
}
}
const state = await this.load();
const startIndex = state.missions.length;

Expand Down Expand Up @@ -733,11 +745,17 @@ export class TowerStore {
scope: input.scope,
action: input.action,
consent_ref: input.consentRef,
token_count: String(input.token_count ?? -1),
});
const content = `${frontmatter}\n\n${input.body.trim()}\n`;
const baseName = inboxFileName({ from: callerName, to, subject: input.subject });
const rel = await this.writeUnique(join(INBOX_DIR, baseName), content);
await this.appendLog(callerName, 'inbox.send', { to, subject: slugify(input.subject) }, rel);
await this.appendLog(
callerName,
'inbox.send',
{ to, subject: slugify(input.subject), token_count: input.token_count ?? -1 },
rel,
);
return rel;
}

Expand Down Expand Up @@ -798,6 +816,7 @@ export class TowerStore {
`**Type**: ${input.type}`,
`**Severity**: ${input.severity ?? 'medium'}`,
`**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`,
`**Token count**: ${String(input.token_count ?? -1)}`,
'',
'---',
'',
Expand Down Expand Up @@ -829,7 +848,12 @@ export class TowerStore {
slug: input.title,
});
const rel = await this.writeUnique(join(FINDINGS_DIR, baseName), lines.join('\n'));
await this.appendLog(callerName, 'finding.file', { type: input.type, slug: slugify(input.title) }, rel);
await this.appendLog(
callerName,
'finding.file',
{ type: input.type, slug: slugify(input.title), token_count: input.token_count ?? -1 },
rel,
);
return rel;
}

Expand Down Expand Up @@ -875,6 +899,7 @@ export class TowerStore {
merge: input.merge,
reviewed_commit: reviewedCommit,
mission: reviewMissionId,
token_count: String(input.token_count ?? -1),
});
const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n');
const content = [
Expand All @@ -899,7 +924,13 @@ export class TowerStore {
await this.appendLog(
callerName,
'review.write',
{ target: input.target, round, verdict: input.status, reviewed: reviewedCommit.slice(0, 7) },
{
target: input.target,
round,
verdict: input.status,
reviewed: reviewedCommit.slice(0, 7),
token_count: input.token_count ?? -1,
},
rel,
);
return rel;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { toInputJsonSchema } from '#/tool/input-schema';
import type { ToolExecution } from '#/tool/toolContract';

import { callerName, newTowerStore, runTowerTool } from '../support';
import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support';
import DESCRIPTION from './finding.md?raw';
import {
ITowerFindingTool,
Expand All @@ -20,6 +21,7 @@ export class TowerFindingTool implements ITowerFindingTool {
constructor(
@ISessionContext private readonly sessionContext: ISessionContext,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@ISessionUsageService private readonly usage: ISessionUsageService,
) {}

resolveExecution(args: TowerFindingToolInput): ToolExecution {
Expand All @@ -39,6 +41,7 @@ export class TowerFindingTool implements ITowerFindingTool {
location: args.location,
details: args.details,
suggestedFix: args.suggested_fix,
token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)),
});
return {
output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ Split the tower goal into missions. Each mission gets an id (M1, M2, …), a bra

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), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first).
Rules enforced by the store: scopes of build missions must be pairwise disjoint (survey missions are read-only and reserve no scope), deps must reference existing mission ids, and mission branches must be unique — a title whose slugged branch collides with any existing mission's branch (including abandoned or merged ones) or with an unowned local git branch is rejected, so rename the title and plan again. Titles must be printable ASCII English: a title containing any non-ASCII character (CJK, Cyrillic, Hangul, accented letters) is rejected outright, because non-ASCII text slugs to a generic word like "item" that collides across missions, while printable ASCII punctuation (spaces, "-", "_", "+") folds into the slug without losing words — write the title in English and include a unique identifier word (e.g. a business code like B010100). Plan once, then spawn one worker per mission with TowerSpawn. Requires an active tower workspace (run TowerInit first).
6 changes: 5 additions & 1 deletion packages/agent-core-v2/src/features/tower/tools/plan/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ export const TowerPlanToolInputSchema = z
.array(
z
.object({
title: z.string().describe('Short mission title; becomes the branch/worktree slug'),
title: z
.string()
.describe(
'Short mission title; becomes the branch/worktree slug. Printable ASCII English only — any non-ASCII character is rejected. Include a unique identifier word (a business code, a module name, a ticket id).',
),
scope: z
.array(z.string())
.min(1)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { toInputJsonSchema } from '#/tool/input-schema';
import type { ToolExecution } from '#/tool/toolContract';

import { callerName, newTowerStore, runTowerTool } from '../support';
import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support';
import DESCRIPTION from './review.md?raw';
import {
ITowerReviewTool,
Expand All @@ -20,6 +21,7 @@ export class TowerReviewTool implements ITowerReviewTool {
constructor(
@ISessionContext private readonly sessionContext: ISessionContext,
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@ISessionUsageService private readonly usage: ISessionUsageService,
) {}

resolveExecution(args: TowerReviewToolInput): ToolExecution {
Expand All @@ -38,6 +40,7 @@ export class TowerReviewTool implements ITowerReviewTool {
findings: args.findings,
checks: args.checks,
decision: args.decision,
token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)),
});
return {
output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
import { IAgentTaskService } from '#/agent/task/task';
import { ISessionEventBus } from '#/app/event/eventBus';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionUsageService } from '#/session/usage/sessionUsage';
import { toInputJsonSchema } from '#/tool/input-schema';
import type { ToolExecution } from '#/tool/toolContract';

import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index';
import { TowerInboxSent } from '#/features/tower/towerOps';
import { callerName, newTowerStore, runTowerTool } from '../support';
import { callerName, callerTokenCount, newTowerStore, runTowerTool } from '../support';
import DESCRIPTION from './send.md?raw';
import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send';

Expand All @@ -22,6 +23,7 @@ export class TowerSendTool implements ITowerSendTool {
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
@ISessionEventBus private readonly sessionBus: ISessionEventBus,
@IAgentTaskService private readonly tasks: IAgentTaskService,
@ISessionUsageService private readonly usage: ISessionUsageService,
) {}

resolveExecution(args: TowerSendToolInput): ToolExecution {
Expand All @@ -41,6 +43,7 @@ export class TowerSendTool implements ITowerSendTool {
scope: args.scope,
action: args.action,
consentRef: args.consent_ref,
token_count: callerTokenCount(this.usage, agentContextOfScope(this.scopeContext)),
});
if (
this.sessionBus !== undefined &&
Expand Down
24 changes: 16 additions & 8 deletions packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,22 @@ export class TowerSpawnTool implements ITowerSpawnTool {
}
}

const prompt = await this.buildPrompt(args, store, state, mission, reviewTarget);
const reviewMission =
reviewTarget !== undefined ? resolveMissionByBranch(state, reviewTarget) : undefined;
const prompt = await this.buildPrompt(
args,
store,
state,
mission,
reviewTarget,
reviewMission,
);
const description =
mission !== undefined
? `tower worker ${args.name}: ${mission.title}`
: `tower reviewer ${args.name}: ${reviewTarget ?? ''}`;
? `${mission.id} ${args.name}: ${mission.title}`
: reviewMission !== undefined
? `${reviewMission.id} review: ${reviewTarget ?? ''}`
: `review ${args.name}: ${reviewTarget ?? ''}`;

const gate = this.rateLimit.acquire();
if (!gate.ok) {
Expand Down Expand Up @@ -234,10 +245,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
kind: args.kind,
missionId: mission?.id,
reviewTarget,
reviewMissionId:
reviewTarget !== undefined
? resolveMissionByBranch(state, reviewTarget)?.id
: undefined,
reviewMissionId: reviewMission?.id,
worktree: mission?.worktree,
branch: mission?.branch,
spawnedAt: new Date().toISOString(),
Expand Down Expand Up @@ -374,6 +382,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
state: TowerState,
mission: TowerMission | undefined,
reviewTarget: string | undefined,
targetMission: TowerMission | undefined,
): Promise<string> {
const extra =
args.instructions !== undefined && args.instructions.trim().length > 0
Expand Down Expand Up @@ -434,7 +443,6 @@ export class TowerSpawnTool implements ITowerSpawnTool {
);
}
const target = reviewTarget ?? '';
const targetMission = resolveMissionByBranch(state, target);
const author = targetMission?.owner;
const reviewBase =
targetMission !== undefined ? await store.diffBase(state, targetMission) : state.base;
Expand Down
Loading
Loading