Skip to content

Commit c1ea5d8

Browse files
committed
fix(agent-core-v2): require ASCII tower mission titles and record token usage
Non-ASCII mission titles slug to a generic branch name that collides across missions, so TowerPlan now rejects them with a rewrite hint. Tower messages, findings, and reviews record the sender's cumulative token count, and task cards lead with the mission id.
1 parent eb3cb77 commit c1ea5d8

15 files changed

Lines changed: 291 additions & 23 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
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.

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Working principles:
2121
## Tower workflow
2222

2323
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.
24-
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.
24+
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.
2525
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`.
2626
4. **Supervise** — on every wake (worker completion, human message): `TowerInbox` and `TowerStatus`, then act:
2727
- 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.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ export function slugify(text: string, maxLength = 60): string {
4040
return slug.length > 0 ? slug : 'item';
4141
}
4242

43+
export function hasNonAsciiCharacters(text: string): boolean {
44+
return /[^\u0020-\u007E]/.test(text);
45+
}
46+
4347
export function targetSlug(target: string): string {
4448
const cleaned = target.trim().replace(/^#/, 'pr');
4549
return slugify(cleaned.replaceAll(/[/#]+/g, '-'));

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
isReservedTowerAgentName,
4242
dateDash,
4343
findingFileName,
44+
hasNonAsciiCharacters,
4445
inboxFileName,
4546
missionFileName,
4647
reviewFileName,
@@ -91,6 +92,7 @@ export interface TowerSendInput {
9192
readonly scope?: string;
9293
readonly action?: string;
9394
readonly consentRef?: string;
95+
readonly tokens?: number;
9496
}
9597

9698
export interface TowerFindingInput {
@@ -101,6 +103,7 @@ export interface TowerFindingInput {
101103
readonly location?: string;
102104
readonly details: string;
103105
readonly suggestedFix: string;
106+
readonly tokens?: number;
104107
}
105108

106109
export interface TowerReviewInput {
@@ -110,6 +113,7 @@ export interface TowerReviewInput {
110113
readonly findings: string;
111114
readonly checks?: readonly string[];
112115
readonly decision: string;
116+
readonly tokens?: number;
113117
}
114118

115119
export interface TowerMissionPatch {
@@ -497,6 +501,14 @@ export class TowerStore {
497501
if (input.length === 0) {
498502
throw new TowerProtocolError('TowerPlan needs at least one mission');
499503
}
504+
for (const item of input) {
505+
if (hasNonAsciiCharacters(item.title)) {
506+
const offending = /[^\u0020-\u007E]/.exec(item.title)![0];
507+
throw new TowerProtocolError(
508+
`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`,
509+
);
510+
}
511+
}
500512
const state = await this.load();
501513
const startIndex = state.missions.length;
502514

@@ -733,11 +745,17 @@ export class TowerStore {
733745
scope: input.scope,
734746
action: input.action,
735747
consent_ref: input.consentRef,
748+
tokens: String(input.tokens ?? -1),
736749
});
737750
const content = `${frontmatter}\n\n${input.body.trim()}\n`;
738751
const baseName = inboxFileName({ from: callerName, to, subject: input.subject });
739752
const rel = await this.writeUnique(join(INBOX_DIR, baseName), content);
740-
await this.appendLog(callerName, 'inbox.send', { to, subject: slugify(input.subject) }, rel);
753+
await this.appendLog(
754+
callerName,
755+
'inbox.send',
756+
{ to, subject: slugify(input.subject), tokens: input.tokens ?? -1 },
757+
rel,
758+
);
741759
return rel;
742760
}
743761

@@ -798,6 +816,7 @@ export class TowerStore {
798816
`**Type**: ${input.type}`,
799817
`**Severity**: ${input.severity ?? 'medium'}`,
800818
`**Mission**: ${mission === undefined ? '(none)' : `${mission.id} — ${mission.title}`}`,
819+
`**Tokens**: ${String(input.tokens ?? -1)}`,
801820
'',
802821
'---',
803822
'',
@@ -829,7 +848,12 @@ export class TowerStore {
829848
slug: input.title,
830849
});
831850
const rel = await this.writeUnique(join(FINDINGS_DIR, baseName), lines.join('\n'));
832-
await this.appendLog(callerName, 'finding.file', { type: input.type, slug: slugify(input.title) }, rel);
851+
await this.appendLog(
852+
callerName,
853+
'finding.file',
854+
{ type: input.type, slug: slugify(input.title), tokens: input.tokens ?? -1 },
855+
rel,
856+
);
833857
return rel;
834858
}
835859

@@ -875,6 +899,7 @@ export class TowerStore {
875899
merge: input.merge,
876900
reviewed_commit: reviewedCommit,
877901
mission: reviewMissionId,
902+
tokens: String(input.tokens ?? -1),
878903
});
879904
const checks = (input.checks ?? []).map((c) => `- [x] ${c}`).join('\n');
880905
const content = [
@@ -899,7 +924,13 @@ export class TowerStore {
899924
await this.appendLog(
900925
callerName,
901926
'review.write',
902-
{ target: input.target, round, verdict: input.status, reviewed: reviewedCommit.slice(0, 7) },
927+
{
928+
target: input.target,
929+
round,
930+
verdict: input.status,
931+
reviewed: reviewedCommit.slice(0, 7),
932+
tokens: input.tokens ?? -1,
933+
},
903934
rel,
904935
);
905936
return rel;

‎packages/agent-core-v2/src/features/tower/tools/finding/findingTool.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
1+
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
22
import { ISessionContext } from '#/session/sessionContext/sessionContext';
3+
import { ISessionUsageService } from '#/session/usage/sessionUsage';
34
import { toInputJsonSchema } from '#/tool/input-schema';
45
import type { ToolExecution } from '#/tool/toolContract';
56

6-
import { callerName, newTowerStore, runTowerTool } from '../support';
7+
import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support';
78
import DESCRIPTION from './finding.md?raw';
89
import {
910
ITowerFindingTool,
@@ -20,6 +21,7 @@ export class TowerFindingTool implements ITowerFindingTool {
2021
constructor(
2122
@ISessionContext private readonly sessionContext: ISessionContext,
2223
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
24+
@ISessionUsageService private readonly usage: ISessionUsageService,
2325
) {}
2426

2527
resolveExecution(args: TowerFindingToolInput): ToolExecution {
@@ -39,6 +41,7 @@ export class TowerFindingTool implements ITowerFindingTool {
3941
location: args.location,
4042
details: args.details,
4143
suggestedFix: args.suggested_fix,
44+
tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)),
4245
});
4346
return {
4447
output: `finding filed: ${rel}\nThe tower will route it — do not fix out-of-scope issues yourself.`,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ Split the tower goal into missions. Each mission gets an id (M1, M2, …), a bra
22

33
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.
44

5-
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).
5+
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).

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ export const TowerPlanToolInputSchema = z
99
.array(
1010
z
1111
.object({
12-
title: z.string().describe('Short mission title; becomes the branch/worktree slug'),
12+
title: z
13+
.string()
14+
.describe(
15+
'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).',
16+
),
1317
scope: z
1418
.array(z.string())
1519
.min(1)

‎packages/agent-core-v2/src/features/tower/tools/review/reviewTool.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
1+
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
22
import { ISessionContext } from '#/session/sessionContext/sessionContext';
3+
import { ISessionUsageService } from '#/session/usage/sessionUsage';
34
import { toInputJsonSchema } from '#/tool/input-schema';
45
import type { ToolExecution } from '#/tool/toolContract';
56

6-
import { callerName, newTowerStore, runTowerTool } from '../support';
7+
import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support';
78
import DESCRIPTION from './review.md?raw';
89
import {
910
ITowerReviewTool,
@@ -20,6 +21,7 @@ export class TowerReviewTool implements ITowerReviewTool {
2021
constructor(
2122
@ISessionContext private readonly sessionContext: ISessionContext,
2223
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
24+
@ISessionUsageService private readonly usage: ISessionUsageService,
2325
) {}
2426

2527
resolveExecution(args: TowerReviewToolInput): ToolExecution {
@@ -38,6 +40,7 @@ export class TowerReviewTool implements ITowerReviewTool {
3840
findings: args.findings,
3941
checks: args.checks,
4042
decision: args.decision,
43+
tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)),
4144
});
4245
return {
4346
output: `review submitted: ${rel}\nAlso notify the branch author (or the tower) with TowerSend so the verdict is seen.`,

‎packages/agent-core-v2/src/features/tower/tools/send/sendTool.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
1+
import { IAgentScopeContext, agentContextOfScope } from '#/agent/scopeContext/scopeContext';
22
import { IAgentTaskService } from '#/agent/task/task';
33
import { ISessionEventBus } from '#/app/event/eventBus';
44
import { ISessionContext } from '#/session/sessionContext/sessionContext';
5+
import { ISessionUsageService } from '#/session/usage/sessionUsage';
56
import { toInputJsonSchema } from '#/tool/input-schema';
67
import type { ToolExecution } from '#/tool/toolContract';
78

89
import { BROADCAST_NAME, TOWER_NAME } from '#/features/tower/protocol/index';
910
import { TowerInboxSent } from '#/features/tower/towerOps';
10-
import { callerName, newTowerStore, runTowerTool } from '../support';
11+
import { callerName, callerTokens, newTowerStore, runTowerTool } from '../support';
1112
import DESCRIPTION from './send.md?raw';
1213
import { ITowerSendTool, TowerSendToolInputSchema, type TowerSendToolInput } from './send';
1314

@@ -22,6 +23,7 @@ export class TowerSendTool implements ITowerSendTool {
2223
@IAgentScopeContext private readonly scopeContext: IAgentScopeContext,
2324
@ISessionEventBus private readonly sessionBus: ISessionEventBus,
2425
@IAgentTaskService private readonly tasks: IAgentTaskService,
26+
@ISessionUsageService private readonly usage: ISessionUsageService,
2527
) {}
2628

2729
resolveExecution(args: TowerSendToolInput): ToolExecution {
@@ -41,6 +43,7 @@ export class TowerSendTool implements ITowerSendTool {
4143
scope: args.scope,
4244
action: args.action,
4345
consentRef: args.consent_ref,
46+
tokens: callerTokens(this.usage, agentContextOfScope(this.scopeContext)),
4447
});
4548
if (
4649
this.sessionBus !== undefined &&

‎packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,22 @@ export class TowerSpawnTool implements ITowerSpawnTool {
171171
}
172172
}
173173

174-
const prompt = await this.buildPrompt(args, store, state, mission, reviewTarget);
174+
const reviewMission =
175+
reviewTarget !== undefined ? resolveMissionByBranch(state, reviewTarget) : undefined;
176+
const prompt = await this.buildPrompt(
177+
args,
178+
store,
179+
state,
180+
mission,
181+
reviewTarget,
182+
reviewMission,
183+
);
175184
const description =
176185
mission !== undefined
177-
? `tower worker ${args.name}: ${mission.title}`
178-
: `tower reviewer ${args.name}: ${reviewTarget ?? ''}`;
186+
? `${mission.id} ${args.name}: ${mission.title}`
187+
: reviewMission !== undefined
188+
? `${reviewMission.id} review: ${reviewTarget ?? ''}`
189+
: `review ${args.name}: ${reviewTarget ?? ''}`;
179190

180191
const gate = this.rateLimit.acquire();
181192
if (!gate.ok) {
@@ -234,10 +245,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
234245
kind: args.kind,
235246
missionId: mission?.id,
236247
reviewTarget,
237-
reviewMissionId:
238-
reviewTarget !== undefined
239-
? resolveMissionByBranch(state, reviewTarget)?.id
240-
: undefined,
248+
reviewMissionId: reviewMission?.id,
241249
worktree: mission?.worktree,
242250
branch: mission?.branch,
243251
spawnedAt: new Date().toISOString(),
@@ -374,6 +382,7 @@ export class TowerSpawnTool implements ITowerSpawnTool {
374382
state: TowerState,
375383
mission: TowerMission | undefined,
376384
reviewTarget: string | undefined,
385+
targetMission: TowerMission | undefined,
377386
): Promise<string> {
378387
const extra =
379388
args.instructions !== undefined && args.instructions.trim().length > 0
@@ -434,7 +443,6 @@ export class TowerSpawnTool implements ITowerSpawnTool {
434443
);
435444
}
436445
const target = reviewTarget ?? '';
437-
const targetMission = resolveMissionByBranch(state, target);
438446
const author = targetMission?.owner;
439447
const reviewBase =
440448
targetMission !== undefined ? await store.diffBase(state, targetMission) : state.base;

0 commit comments

Comments
 (0)