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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,13 @@ Four fixes from dogfooding the fleet on itself (issues #58–#61):
- **Zero-tool-call flag (#61)** — a run that "completes" without a single executed tool call (the premature-return shape: narrate a plan, end) is prefixed with `[FLEET] zero-tool-call run — likely a premature return` in the tool result; `details.toolCallCount` exposes the count. Verify (git status/log) before trusting such a result.
- **Richer run journal** — `run:ended` now carries `error` (failure reason), `filesTouched` (#49 parity in the durable journal — real SDK args are captured from `tool_execution_start`; the end event has none), and `toolCallCount`.

## Model-quality drift signals (v1.2.0)

Cheap, additive signals for the output-quality drift observed on flat/cheap model tiers (issues #88/#89). Both are **flagging, not blocking** — findings were sound every time; the controller decides.

- **Language-drift flag (#88)** — when a completed run's final report is majority CJK-family script (Han/Hiragana/Katakana/Hangul ≥ 30% of letters, min 40 letters), the tool result is prefixed with `[FLEET] language drift — final report is N% CJK-family script (#88)…` and `details.languageDrift`/`languageDriftRatio` are set. Observed: glm-5.3-flash drifted into Chinese in 4/7 dispatches, *even with an explicit English-only instruction in the prompt*. Quoted CJK content inside an English report stays clean; `run:ended` journals the flag for post-hoc diagnosis. Latin-script non-English (fr/de/…) is deliberately not detected (see the spec's non-goals).
- **Reviewer cross-domain references (#89, guidance)** — reviewer subagents on cheap tiers sometimes fabricate plausible-sounding *process* references (briefs, roll-ups, sibling-repo fixes) while their verdicts and code findings remain verifiable. Rule for controllers: **treat any reviewer reference to an artifact outside the provided brief/report/diff as suspect — verify findings by git + file contents, never by report prose.** The fleet's structured result fields (`details.*`) and the journal are the trustworthy surface; the narrative tail is not.

## Roadmap

armory-fleet follows a PRD → SPEC-N (brainstorm → spec → plan → implementation) pipeline. **16/16 phases done through v0.12.0.**
Expand Down
69 changes: 69 additions & 0 deletions docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Spec: #88 — language-drift flag in subagent result envelopes

**Date:** 2026-09-02 · **Status:** approved (RECTOR: "do you rec, full power") · **Issue:** #88

## Problem

On the 2026-08-31 armory-gateway SDD run, `zai/glm-5.3-flash` drifted into Chinese for all
or part of the final report in **4 of 7 dispatches** — including two whose dispatch prompt
contained an explicit "RESPOND IN ENGLISH ONLY" instruction. Findings were sound every
time; the friction is that the controller has **no cheap signal** and must eyeball every
verdict line. The drift family (CJK) is specific to the model tier the fleet steers people
to (flat z.ai/GLM plans).

## Goals

- G1: controller gets a machine-readable flag on every dispatch whose final report is
majority CJK-family script — without reading the report.
- G2: flag is **additive, surfacing-only** (the #61 precedent): never blocks, never
rewrites, never re-dispatches on its own.
- G3: survives the journal — a drifted run is diagnosable post-hoc from `run:ended`
(the #59/#60/#61 pattern: terminal facts land in the journal, not just the result).
- G4: zero config by default; no new required settings, no dispatch-param surface yet.

## Non-goals

- N1: **Not** auto re-dispatch / auto-translate (controller's call).
- N2: **Not** the `resultLanguage` system-side preference (issue's "optional") —
dispatch-prompt placement demonstrably isn't enough and a system-prompt edit changes
every child's substrate; re-file standalone if wanted.
- N3: **Not** Latin-script non-English detection (fr, de, …): legitimate quoted content
would false-positive, and the observed drift family is CJK. Scope is documented in the
detector.

## Design decisions

- **D1 — signal: CJK-family letter ratio over the final message.**
`detectLanguageDrift(text)` counts Han/Hiragana/Katakana/Hangul codepoints over the
letter population (Latin + Latin-extended + CJK family). Flags when ratio ≥ 0.3 AND
letters ≥ 40 (short strings can't triage). Constants exported + tunable.
Alternatives rejected: full language-ID library (dependency weight for a heuristic);
any-non-ASCII (false-positives on quotes/emoji/CJK identifiers).
- **D2 — detection site: `finishRun`** (single composition point for every terminal path —
direct, fallback retry, turn-budget; both backends since it reads `finalText`).
Computed once, placed on the SpawnResult + journaled.
- **D3 — SpawnResult surface:** `languageDrift?: boolean` + `languageDriftRatio?: number`
(ratio gives severity; boolean is the greppable flag the issue asked for).
`run:ended` gains `languageDrift?: boolean` + `languageDriftRatio?: number`
(**additive** — the frozen RPC/journal surface allows additive fields; pinned tests
updated, no renames).
- **D4 — tool output: one-line warning prefix** (mirrors #61's zero-tool prefix):
`[FLEET] language drift — final report is N% CJK-family script (#88); findings may
still be sound — re-dispatch or translate if the controller requires English.`
Only on non-error results (a failed run's error text needs no drift triage).
Details expose both fields.

## Wiring

1. `src/engine/language-drift.ts` (new): pure detector + constants.
2. `src/engine/spawnSubagent.ts` `finishRun`: compute, return on both paths, journal.
3. `src/runtime/run-log.ts` `RunEndedEvent`: additive optional fields.
4. `src/tools/subagent.ts`: warning prefix + `details`.

## Test plan

- Pure detector: English no-drift; pure-CJK drift; ~50% mixed drift; Korean/Japanese in
family; short-text floor; empty string.
- Tool wiring: drifted child → prefix + details fields; English child → no prefix,
`languageDrift` undefined; journaled `run:ended` carries the flag (RunLog replay).
- All env-independent (no providers, tmpdir only).
43 changes: 43 additions & 0 deletions src/engine/language-drift.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// src/engine/language-drift.ts
// #88: cheap post-run signal for non-English drift in subagent final reports. Observed:
// zai/glm-5.3-flash drifted into Chinese for all or part of the report in 4/7 dispatches
// — even with an explicit English-only instruction in the dispatch prompt. Findings were
// sound every time, so this is FLAGGING, not blocking: the controller decides whether to
// re-dispatch or translate. Spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md
//
// Scope (deliberate): CJK-family scripts (Han / Hiragana / Katakana / Hangul) — the drift
// family actually observed on the model tiers the fleet steers people to. Quoted CJK
// content inside an otherwise-English report stays well under the threshold; a
// majority-CJK report trips it. Latin-script non-English (fr/de/…) is NOT detected — see
// spec N3.

export interface LanguageDrift {
/** true when the CJK-family letter ratio crossed DRIFT_RATIO (and MIN_LETTERS was met). */
drift: boolean;
/** CJK-family letters / total letters (0 when the text has no letters). */
ratio: number;
}

/** CJK-family: Hiragana+Katakana (3040–30FF), CJK ext-A (3400–4DBF), CJK Unified
* (4E00–9FFF), Hangul syllables (AC00–D7AF), CJK compat ideographs (F900–FAFF).
* (#88 review NIT: plane-2 Han extensions, Hangul Jamo, and halfwidth katakana are
* deliberately not counted — never observed in model final reports; extend if a
* real case appears.) */
const CJK_RE = /[\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g;
/** Letter population: ASCII + Latin-extended + the CJK family (denominator for the ratio). */
const LETTER_RE = /[A-Za-z\u00C0-\u024F\u3040-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/g;

/** Ratio at or above which a report is considered drifted. 0.3 ≈ a third of the letters —
* comfortably above any plausible quoting noise, well below the ~0.5 mixed-report case. */
export const DRIFT_RATIO = 0.3;

/** Reports shorter than this can't be triaged (a one-line verdict quoting '通过' must not flag). */
export const MIN_LETTERS = 40;

export function detectLanguageDrift(text: string): LanguageDrift {
const cjk = (text.match(CJK_RE) ?? []).length;
const letters = (text.match(LETTER_RE) ?? []).length;
if (letters === 0) return { drift: false, ratio: 0 };
const ratio = cjk / letters;
return { drift: letters >= MIN_LETTERS && ratio >= DRIFT_RATIO, ratio };
}
19 changes: 17 additions & 2 deletions src/engine/spawnSubagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { RunLog } from "../runtime/run-log.ts";
import { buildToolEvent } from "../runtime/run-log.ts";
import { resolveAgentModel, type ModelRegistryLike } from "../tiers/resolve.ts";
import { TierRegistry } from "../tiers/tier-registry.ts";
import { detectLanguageDrift } from "./language-drift.ts";

const PI_DEFAULT_TOOLS = ["read", "bash", "edit", "write"];

Expand Down Expand Up @@ -201,6 +202,11 @@ export interface SpawnResult {
/** #61: number of executed tool calls. A "completed" run with 0 is the premature-return
* shape (the child narrated and ended without acting) — the tool flags it in the result. */
toolCallCount?: number;
/** #88: CJK-family drift flag on the final report (flagging, not blocking — the controller
* decides whether to re-dispatch/translate). Undefined when clean or too short to triage. */
languageDrift?: boolean;
/** #88: CJK-family letter ratio that produced the flag (severity, 0–1). */
languageDriftRatio?: number;
}

/** #49: extract file paths a tool event touched, for the structured partial-result report.
Expand Down Expand Up @@ -639,6 +645,12 @@ async function finishRun(a: FinishRunArgs): Promise<SpawnResult> {
const contextTokens = a.contextTokens ?? 0;
const toolCallCount = a.toolCallCount ?? 0;
const { retryable, filesTouched, reachedSummary } = a;
// #88: cheap CJK-family drift signal over the final report — flagging, not blocking
// (spec: docs/superpowers/specs/2026-09-02-spec-language-drift-flag.md). Undefined when
// clean so the 99% case keeps result + journal unchanged.
const drift = detectLanguageDrift(finalText);
const languageDrift = drift.drift ? true : undefined;
const languageDriftRatio = drift.drift ? drift.ratio : undefined;
if (finalizedRunIds.has(runId)) {
// Already finalized — return the existing registry record's result without re-appending.
const existing = opts.runRegistry.get(runId);
Expand All @@ -647,7 +659,7 @@ async function finishRun(a: FinishRunArgs): Promise<SpawnResult> {
runId, todoId, agent: agentName, model,
durationMs: existing?.endedAt ? existing.endedAt - startedAt : Date.now() - startedAt,
tokenTotal, costTotal, contextTokens, error, retryable,
filesTouched, reachedSummary, toolCallCount,
filesTouched, reachedSummary, toolCallCount, languageDrift, languageDriftRatio,
};
}
finalizedRunIds.add(runId);
Expand All @@ -671,6 +683,9 @@ async function finishRun(a: FinishRunArgs): Promise<SpawnResult> {
toolCallCount,
// #60: what the run mutated (was only on the SpawnResult; the durable journal lacked it).
filesTouched,
// #88: drift flag + ratio on the final report (post-hoc diagnosability).
languageDrift,
languageDriftRatio,
});
} catch { /* best-effort: journal is the index, not the product */ }
// SPEC-4: lifecycle phase children skip the per-run todo reconciliation — the lifecycle
Expand All @@ -689,6 +704,6 @@ async function finishRun(a: FinishRunArgs): Promise<SpawnResult> {
return {
status, finalText, runId, todoId, agent: agentName, model,
durationMs: endedAt - startedAt, tokenTotal, costTotal, contextTokens, error, retryable,
filesTouched, reachedSummary, toolCallCount,
filesTouched, reachedSummary, toolCallCount, languageDrift, languageDriftRatio,
};
}
2 changes: 2 additions & 0 deletions src/rpc/event-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ export class FleetEventBus {
...(e.error !== undefined ? { error: e.error } : {}),
...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}),
...(e.languageDrift !== undefined ? { languageDrift: e.languageDrift } : {}),
...(e.languageDriftRatio !== undefined ? { languageDriftRatio: e.languageDriftRatio } : {}),
...(start !== undefined ? { durationMs: e.endedAt - start } : {}),
});
}
Expand Down
4 changes: 3 additions & 1 deletion src/rpc/rpc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ export class RpcServer {
...(e.resultSummary !== undefined ? { result: e.resultSummary } : {}),
...(e.error !== undefined ? { error: e.error } : {}),
...(e.filesTouched !== undefined ? { filesTouched: e.filesTouched } : {}),
...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}) },
...(e.toolCallCount !== undefined ? { toolCallCount: e.toolCallCount } : {}),
...(e.languageDrift !== undefined ? { languageDrift: e.languageDrift } : {}),
...(e.languageDriftRatio !== undefined ? { languageDriftRatio: e.languageDriftRatio } : {}) },
});
}
});
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/run-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ export interface RunEndedEvent {
toolCallCount?: number;
/** #60: file paths the run mutated (post-hoc — was previously SpawnResult-only). */
filesTouched?: string[];
/** #88: CJK-family drift flag on the final report (flagging, not blocking). */
languageDrift?: boolean;
/** #88: CJK-family letter ratio that produced the flag (severity, 0–1). */
languageDriftRatio?: number;
}
export type RunLogEvent = RunMetaEvent | MessageEvent | ToolEvent | RunEndedEvent;

Expand Down
16 changes: 14 additions & 2 deletions src/tools/subagent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,20 @@ export function createSubagentTool(deps: SubagentToolDeps) {
// (the child narrated a plan and ended without acting) — flag it in-band so the controller
// verifies (git status/log) instead of trusting a terse planning statement as a completion.
const zeroToolRun = !isError && (finalRes.toolCallCount ?? 0) === 0;
// #88: CJK-family drift in the final report — flag in-band so the controller can
// re-dispatch or translate without eyeballing every verdict line (spec: docs/superpowers/
// specs/2026-09-02-spec-language-drift-flag.md). Findings may still be sound.
const driftWarning = !isError && finalRes.languageDrift
? `[FLEET] language drift — final report is ${Math.round((finalRes.languageDriftRatio ?? 0) * 100)}% CJK-family script (#88); findings may still be sound — re-dispatch or translate if the controller requires English.`
: null;
const zeroToolWarning = zeroToolRun
? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.`
: null;
const warnings = [zeroToolWarning, driftWarning].filter((w): w is string => w !== null);
const resultText = isError
? (finalRes.error ?? finalRes.status)
: zeroToolRun
? `[FLEET] zero-tool-call run — likely a premature return (#61); verify with git status/log before trusting this result.\n\n${finalRes.finalText}`
: warnings.length > 0
? `${warnings.join("\n\n")}\n\n${finalRes.finalText}`
: finalRes.finalText;
return {
content: [{ type: "text" as const, text: resultText }],
Expand All @@ -275,6 +285,8 @@ export function createSubagentTool(deps: SubagentToolDeps) {
retriedWithModel,
filesTouched: finalRes.filesTouched, reachedSummary: finalRes.reachedSummary,
toolCallCount: finalRes.toolCallCount, // #61: zero = the premature-return signal
languageDrift: finalRes.languageDrift, // #88: CJK-family drift flag
languageDriftRatio: finalRes.languageDriftRatio,
},
isError,
};
Expand Down
19 changes: 19 additions & 0 deletions test/fleet-event-bus.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,22 @@ test("a throwing emit() never breaks the append path", () => {
bus.dispose();
} finally { rmSync(dir, { recursive: true, force: true }); }
});

test("#88: languageDrift fields forward on fleet:run:ended when set; absent when clean", () => {
const h = harness();
try {
h.runLog.append("fl-d", { type: "run:meta", runId: "fl-d", agent: "scout", model: "m", task: "t", startedAt: 1000, track: true, todoId: null });
h.runLog.append("fl-d", { type: "run:ended", runId: "fl-d", status: "completed", endedAt: 1500, tokenTotal: 3, languageDrift: true, languageDriftRatio: 0.62 });
let last = h.emitted[h.emitted.length - 1]!;
assert.equal(last.channel, "fleet:run:ended");
assert.equal(last.payload.languageDrift, true, "drift flag forwards on the live bus");
assert.equal(last.payload.languageDriftRatio, 0.62, "ratio forwards on the live bus");

h.runLog.append("fl-d2", { type: "run:meta", runId: "fl-d2", agent: "scout", model: "m", task: "t", startedAt: 2000, track: true, todoId: null });
h.runLog.append("fl-d2", { type: "run:ended", runId: "fl-d2", status: "completed", endedAt: 2500, tokenTotal: 3 });
last = h.emitted[h.emitted.length - 1]!;
assert.equal(last.channel, "fleet:run:ended");
assert.equal("languageDrift" in last.payload, false, "clean run payload stays clean (no key)");
assert.equal("languageDriftRatio" in last.payload, false, "clean run ratio stays absent (no key)");
} finally { h.bus.dispose(); rmSync(h.dir, { recursive: true, force: true }); }
});
Loading
Loading