fix: pre-landing review fixes

Ten review findings, all two-way doors:

1. health-indicators error rate no longer counts success_with_warnings /
   surface_change rows as errors (denied_after_list stays counted).
2. visibleOpsForCaller treats the trusted local CLI (remote === false) like
   stdio: localOnly + publish-gated ops stay visible to the operator who can
   actually call them.
3. request_tools dry-run previews no longer consume the persist rate-limit
   budget (denials still exercised; limiter meters actual writes only).
4. normalizeLoggedOperation re-runs the NON_OP_LOG_ROWS hygiene check on
   legacy-prefix-stripped names ('tools/call:tools/list' no longer counts
   as usage).
5. LLM_CALL_FAILED warnings carry a closed-vocabulary class (timeout |
   rate_limited | network | provider_error) instead of raw provider text;
   the raw message goes to stderr. MEMORY_VERBS doc + schema updated.
6. Publish-gate + strict-params config reads are issued concurrently
   (tools/list RTT depth 3 -> 1).
7. resolveEffectiveSurface skips the default-surface config read when the
   clamped ceiling is already 'verbs' (min() cannot go lower).
8. buildQueueDepths / doctor waitingByQueue comments now state the truth:
   the wedge index gives no prefix access for a status-only WHERE; these
   full-scan today.
9. Verb-count comments updated to the seven frozen verbs + starter tier.
10. ALWAYS_INCLUDED_STARTER_OPS exported from surface.ts and consumed by the
    advisor starter-fit collector (which omitted the agent lane, producing a
    perpetual bogus unused-starter finding) and derive-starter-ops.

Also extracts requestLogStatusForResult (src/mcp/dispatch.ts) as the one
request-log status decision serve-http persists — behavior identical, unit
pins land in the follow-up test commit. Behavior pins for fixes 2-5 and 10
ride here so every commit stays green (test/request-tools, test/mcp-usage,
test/think-extractive.serial, test/advisor-mcp-client-fit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-13 16:00:48 -07:00
co-authored by Claude Fable 5
parent c599d50968
commit a8f502857b
19 changed files with 264 additions and 82 deletions
+3 -1
View File
@@ -214,7 +214,9 @@ pre-v0.45.x servers; a server that omits them still certifies):
fallback or a typed error and never emits them itself.
- `pages_gathered` / `takes_gathered` — retrieval counts behind the answer.
- `warnings` — machine-stable pipeline warning codes (e.g.
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <detail>`,
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <class>`
where `<class>` is one of the closed set `timeout` | `rate_limited` |
`network` | `provider_error` — raw provider detail never rides the wire,
`MODEL_NOT_USABLE:<reason>`).
Precedence (frozen): compose failure + NON-EMPTY gather ⇒
+3 -1
View File
@@ -4542,7 +4542,9 @@ pre-v0.45.x servers; a server that omits them still certifies):
fallback or a typed error and never emits them itself.
- `pages_gathered` / `takes_gathered` — retrieval counts behind the answer.
- `warnings` — machine-stable pipeline warning codes (e.g.
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <detail>`,
`LLM_OUTPUT_NOT_JSON`, `SYNTHESIS_EMPTY_ANSWER`, `LLM_CALL_FAILED: <class>`
where `<class>` is one of the closed set `timeout` | `rate_limited` |
`network` | `provider_error` — raw provider detail never rides the wire,
`MODEL_NOT_USABLE:<reason>`).
Precedence (frozen): compose failure + NON-EMPTY gather ⇒
+3 -4
View File
@@ -31,12 +31,11 @@ import { loadConfig, toEngineConfig } from '../src/core/config.ts';
import { createEngine } from '../src/core/engine-factory.ts';
import { readClientOpUsage, MCP_USAGE_DEFAULT_WINDOW_DAYS } from '../src/core/mcp-usage.ts';
import { operations } from '../src/core/operations.ts';
import { VERB_NAMES } from '../src/core/verbs.ts';
import { ALWAYS_INCLUDED_STARTER_OPS } from '../src/mcp/surface.ts';
import { BRAIN_TOOL_ALLOWLIST } from '../src/core/minions/tools/brain-allowlist.ts';
/** Ops surface.ts always includes regardless of derivation. */
const AGENT_LANE_OPS = ['submit_agent', 'get_agent_job'] as const;
const ALWAYS_INCLUDED = new Set<string>([...VERB_NAMES, 'whoami', 'request_tools', ...AGENT_LANE_OPS]);
/** Ops surface.ts always includes regardless of derivation (shared constant). */
const ALWAYS_INCLUDED = ALWAYS_INCLUDED_STARTER_OPS;
/** Target total STARTER_OPS size (the "~20-op daily-driver set"). */
const DEFAULT_TARGET_SIZE = 20;
+5
View File
@@ -2099,6 +2099,11 @@ export async function computeQueueHealthCheck(
// the oldest waiting job (null when the queue is empty); worker_alive =
// every queue holding waiting work has a live registered worker
// (vacuously true with zero waiting jobs). Messages stay unchanged.
// Perf note (twin of buildQueueDepths in status.ts): WHERE constrains
// only `status` — the second column of the (queue, status, updated_at)
// wedge index — so this GROUP BY full-scans minion_jobs today. Acceptable
// at doctor frequency over pruned waiting sets; a partial
// (queue, created_at) WHERE status='waiting' index is the fix if hot.
const waitingByQueue: Array<{
queue: string;
depth: number | string;
+17 -11
View File
@@ -37,7 +37,7 @@ import {
import type { SqlQuery } from '../core/oauth-provider.ts';
import { hasScope, ALLOWED_SCOPES_LIST, normalizeScopesInput } from '../core/scope.ts';
import { normalizeSourceInput, normalizeFederatedReadInput } from '../core/source-id.ts';
import { summarizeMcpParams, dispatchToolCall, isListLevelDenialEnvelope } from '../mcp/dispatch.ts';
import { summarizeMcpParams, dispatchToolCall, requestLogStatusForResult } from '../mcp/dispatch.ts';
import { resolveStrictParamsMode } from '../mcp/validate-params.ts';
import { buildToolDefs } from '../mcp/tool-defs.ts';
import {
@@ -1428,7 +1428,10 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
try {
const now = Math.floor(Date.now() / 1000);
const [expiring] = await sql`SELECT count(*)::int as count FROM oauth_tokens WHERE token_type = 'access' AND expires_at BETWEEN ${now} AND ${now + 86400}`;
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status != 'success' AND created_at > now() - interval '24 hours'`;
// Excluded from the error numerator: success, success_with_warnings (a
// warn-mode success), and surface_change audit rows; denied_after_list
// stays counted — a denied call IS a failure signal.
const [errors] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE status NOT IN ('success', 'success_with_warnings', 'surface_change') AND created_at > now() - interval '24 hours'`;
const [total] = await sql`SELECT count(*)::int as count FROM mcp_request_log WHERE created_at > now() - interval '24 hours'`;
const errorRate = (total as any).count > 0 ? ((errors as any).count / (total as any).count * 100).toFixed(1) : '0';
res.json({
@@ -1970,6 +1973,9 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
*/
async function resolveEffectiveSurface(authInfo: AuthInfo): Promise<{ ceiling: McpSurface; effective: McpSurface }> {
const ceiling = clampSurface(serverSurfaceCeiling);
// min() can never go below the narrowest surface: a 'verbs' ceiling makes
// the row/default resolution a no-op, so skip the awaited config read.
if (ceiling === 'verbs') return { ceiling, effective: ceiling };
try {
const rowSurface = resolveClientRowSurface(authInfo.surface, authInfo.clientId);
if (rowSurface !== null) return { ceiling, effective: minSurface(ceiling, rowSurface) };
@@ -2027,7 +2033,12 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// hiccup costs at most the 4 gated tools, never the whole list.
// Call-time enforcement (hasScope / fence / assertPublishEnabled)
// stays as the fail-closed backstop for all three layers.
const gateDisabled = await disabledOpsForPublishGates(engine, config);
// Both per-request config reads are independent — issue them
// concurrently (one RTT of latency on network Postgres, not two).
const [gateDisabled, strictParamsMode] = await Promise.all([
disabledOpsForPublishGates(engine, config),
resolveStrictParamsMode(engine, config),
]);
// FOV-4: `agent` deliberately implies only itself, which would strand
// agent-only tokens with ZERO discovery — ops flagged `agentCallable`
// (request_tools) are visible to (and callable by, below) agent scope
@@ -2044,7 +2055,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// PER REQUEST (same restart-free property as the publish gates above):
// 'reject' closes each schema with additionalProperties:false and
// declares the _meta/dry_run passthrough keys (D14.1).
const strictParams = (await resolveStrictParamsMode(engine, config)) === 'reject';
const strictParams = strictParamsMode === 'reject';
const tools = buildToolDefs(visibleOps, { strictParams });
// v0.28.10: log every JSON-RPC method, not just successful tools/call.
// Pre-fix, /admin/api/requests showed nothing for clients that only
@@ -2266,12 +2277,11 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// metric. Argument-level fence denials carry no marker and stay
// 'error' (legitimate for a listed op).
let errMsg = 'unknown_error';
let errStatus = 'error';
try {
const parsed = JSON.parse(toolResult.content[0]?.text ?? '{}');
errMsg = parsed.error?.message ?? parsed.message ?? errMsg;
if (isListLevelDenialEnvelope(parsed)) errStatus = 'denied_after_list';
} catch { /* ignore */ }
const errStatus = requestLogStatusForResult(toolResult);
try {
await executeRawJsonb(
engine,
@@ -2298,11 +2308,7 @@ export async function runServeHttp(engine: BrainEngine, options: ServeHttpOption
// carries a non-empty warnings array logs as 'success_with_warnings' so
// the reject-flip decision is evidence-based (count per client via the
// status column). Warn CONTENTS (the raw unknown keys) are never logged.
const meta = toolResult._meta as Record<string, unknown> | undefined;
const successStatus =
Array.isArray(meta?.warnings) && (meta?.warnings as unknown[]).length > 0
? 'success_with_warnings'
: 'success';
const successStatus = requestLogStatusForResult(toolResult);
try {
await executeRawJsonb(
engine,
+3 -2
View File
@@ -173,8 +173,9 @@ export async function runServe(
const isHttp = args.includes('--http');
// MEMORY_VERBS v1: tool-surface mode. Flag > config `mcp_surface` > 'full'.
// 'verbs' exposes exactly the five protocol verbs (the quickstart surface);
// 'full' (default) keeps every operation — existing installs see no change.
// 'verbs' exposes exactly the seven protocol verbs (the quickstart surface);
// 'starter' the ~20-op daily-driver set; 'full' (default) keeps every
// operation — existing installs see no change.
const { parseSurfaceFlag, resolveSurface } = await import('../mcp/surface.ts');
const { loadConfig } = await import('../core/config.ts');
const surface = resolveSurface(parseSurfaceFlag(args), loadConfig());
+6 -4
View File
@@ -325,10 +325,12 @@ export async function buildQueueCounts(engine: BrainEngine): Promise<QueueCounts
/**
* Per-queue waiting depth + oldest-waiting age. Generalizes the doctor's
* queue_health oldest-age SQL past its embed-backfill-only filter: EVERY
* queue with waiting work reports here, name-agnostic. Perf note: the wave's
* migration (another lane) adds the (queue, status, updated_at) wedge index
* whose (queue, status) prefix serves this GROUP BY; minion_jobs stays small
* enough that the pre-index scan is fine for a snapshot.
* queue with waiting work reports here, name-agnostic. Perf note: WHERE
* constrains only `status` — the SECOND column of the (queue, status,
* updated_at) wedge index — so no prefix access exists and this GROUP BY
* full-scans minion_jobs today. Acceptable at snapshot frequency over pruned
* waiting sets; a partial (queue, created_at) WHERE status='waiting' index
* is the fix if it becomes hot.
*/
export async function buildQueueDepths(engine: BrainEngine): Promise<QueueDepthRow[]> {
const rows = await engine.executeRaw<{
+13 -11
View File
@@ -12,8 +12,9 @@
* `gbrain auth rescope-client <id> --surface starter`.
* (b) Set-level drift (the standing STARTER_OPS curator): top-10 most-used
* ops (ranked by CLIENT COUNT, not raw calls — D12) missing from
* STARTER_OPS, and starter members (excluding the always-included
* verbs + whoami + request_tools) unused for 90d.
* STARTER_OPS, and starter members (excluding
* ALWAYS_INCLUDED_STARTER_OPS — verbs + whoami + request_tools + the
* agent lane) unused for 90d.
*
* Privacy (amendment 29): when the advisor runs REMOTE (ctx.remote), client
* identifiers are REDACTED to aggregate counts ("2 clients fit the starter
@@ -41,8 +42,12 @@
import { gbrainPath } from '../config.ts';
import { readClientOpUsage, type ClientOpUsage } from '../mcp-usage.ts';
import { STARTER_OPS, isMcpSurface, resolveDefaultClientSurface } from '../../mcp/surface.ts';
import { VERB_NAMES } from '../verbs.ts';
import {
STARTER_OPS,
ALWAYS_INCLUDED_STARTER_OPS,
isMcpSurface,
resolveDefaultClientSurface,
} from '../../mcp/surface.ts';
import {
loadNagState,
saveNagState,
@@ -75,11 +80,6 @@ export function __setUsageNagStatePathForTests(path: string | null): void {
_nagPathOverride = path;
}
/** Starter members that are always included by construction — never "drift". */
function alwaysIncludedStarterOps(): Set<string> {
return new Set<string>([...VERB_NAMES, 'whoami', 'request_tools']);
}
/**
* Gate a finding through the nag engine (local runs only). Returns true when
* the finding should surface; records the display so the ceiling counts.
@@ -209,9 +209,11 @@ export const collectMcpClientFit: AdvisorCollector = {
if (u.likely_automation) continue;
for (const op of u.distinct_ops) seen90.add(op);
}
const always = alwaysIncludedStarterOps();
// Always-included-by-construction members (shared with surface.ts +
// derive-starter-ops) never count as drift — including the agent lane,
// whose usage would otherwise flag it "unused" forever.
const unusedStarter = [...STARTER_OPS]
.filter((op) => !always.has(op) && !seen90.has(op))
.filter((op) => !ALWAYS_INCLUDED_STARTER_OPS.has(op) && !seen90.has(op))
.sort();
// Only meaningful once there is real traffic to curate against.
+4 -1
View File
@@ -67,7 +67,10 @@ export const AUTOMATION_FRACTION_THRESHOLD = 0.9;
export function normalizeLoggedOperation(operation: string): string | null {
if (operation.startsWith(LEGACY_CALL_PREFIX)) {
const name = operation.slice(LEGACY_CALL_PREFIX.length);
return name.length > 0 ? name : null;
if (name.length === 0) return null;
// The stripped name re-runs the hygiene check: 'tools/call:tools/list'
// or a prefixed 'surface_change' audit row is still not an op call.
return NON_OP_LOG_ROWS.has(name) ? null : name;
}
if (NON_OP_LOG_ROWS.has(operation)) return null;
return operation;
+31 -22
View File
@@ -1103,9 +1103,11 @@ export interface Operation {
*/
publishGateKey?: 'mcp.publish_skills' | 'mcp.publish_advisor';
/**
* MEMORY_VERBS v1: marks the five frozen protocol verbs (recall, remember,
* entity, synthesize, forget). `gbrain serve --surface verbs` exposes
* EXACTLY the ops with `verb: true`; `full` (default) exposes everything.
* MEMORY_VERBS v1: marks the seven frozen protocol verbs (recall, remember,
* entity, synthesize, forget, context_pack, delta). `gbrain serve --surface
* verbs` exposes EXACTLY the ops with `verb: true`; 'starter' (the ~20-op
* daily-driver tier) sits between verbs and `full` (default), which exposes
* everything.
*/
verb?: boolean;
/**
@@ -7038,22 +7040,26 @@ function firstSentenceOf(description: string): string {
* The set of ops VISIBLE to this caller (never leak hidden names C8/
* amendment 11 class): bounded by the server ceiling (ops above it can
* never be served, so naming them would recreate listed-but-denied at the
* persist level), minus localOnly on network transports (stdio keeps them
* D7), minus ops outside the caller's scopes (agent-callable carve-out per
* FOV-4), minus bound-client-fenced ops (same predicate as tools/list,
* ENG-3), minus publish-gated ops whose gate is off (stdio bypasses gates
* the D7 local-surface posture; a failed gate read hides the gated ops,
* fail-closed).
* persist level), minus localOnly on network transports (stdio and the
* trusted local CLI keep them D7), minus ops outside the caller's scopes
* (agent-callable carve-out per FOV-4), minus bound-client-fenced ops (same
* predicate as tools/list, ENG-3), minus publish-gated ops whose gate is off
* (stdio and the trusted local CLI bypass gates the D7 local-surface
* posture, matching assertPublishEnabled's remote===false exemption; a
* failed gate read hides the gated ops, fail-closed).
*/
async function visibleOpsForCaller(
ctx: OperationContext,
ceiling: 'verbs' | 'starter' | 'full',
): Promise<Operation[]> {
const { filterOpsForSurface } = await import('../mcp/surface.ts');
const isStdio = ctx.transport === 'stdio';
// Trusted local callers: the stdio pipe, or the local CLI (remote is
// strictly false — the fail-closed trust marker). Both CAN call localOnly
// and gated ops, so hiding them would make the catalog dishonest.
const isLocal = ctx.transport === 'stdio' || ctx.remote === false;
let gateDisabled: ReadonlySet<string> = new Set();
if (!isStdio) {
if (!isLocal) {
try {
const { disabledOpsForPublishGates } = await import('../mcp/publish-gates.ts');
gateDisabled = await disabledOpsForPublishGates(ctx.engine, ctx.config);
@@ -7070,7 +7076,7 @@ async function visibleOpsForCaller(
const scopes = ctx.auth?.scopes && ctx.auth.scopes.length > 0 ? ctx.auth.scopes : null;
return filterOpsForSurface(operations, ceiling).filter(op =>
(isStdio || !op.localOnly)
(isLocal || !op.localOnly)
&& (scopes === null
|| hasScope(scopes, op.scope ?? 'read')
|| (op.agentCallable === true && hasScope(scopes, 'agent')))
@@ -7135,14 +7141,6 @@ const request_tools: Operation = {
e.detail = `ceiling=${ceiling}`; // amendment 4 key=value denial grammar; ENG-11 assign-after
throw e;
}
const rl = requestToolsPersistLimiter.check(clientId);
if (!rl.allowed) {
throw new OperationError(
'rate_limited',
'surface persistence is rate-limited to ~5 changes per hour per client (D14.5).',
`Retry after ~${rl.retryAfter ?? 60}s.`,
);
}
let rows: Record<string, unknown>[];
try {
rows = await ctx.engine.executeRaw(
@@ -7172,9 +7170,19 @@ const request_tools: Operation = {
return e;
};
if (current.surface_set_by === 'operator') throw operatorLocked();
// A dry-run preview exercises every denial above but must not consume
// the persist budget — the limiter meters actual writes only.
if (ctx.dryRun) {
return { persisted: false, dry_run: true, surface: requested, reason: 'dry_run' };
}
const rl = requestToolsPersistLimiter.check(clientId);
if (!rl.allowed) {
throw new OperationError(
'rate_limited',
'surface persistence is rate-limited to ~5 changes per hour per client (D14.5).',
`Retry after ~${rl.retryAfter ?? 60}s.`,
);
}
// Atomic re-check: a concurrent operator pin between the SELECT and
// this UPDATE must still win.
const updated = await ctx.engine.executeRaw(
@@ -7236,8 +7244,9 @@ const request_tools: Operation = {
export const operations: Operation[] = [
// MEMORY_VERBS v1 (Cathedral 1) — remember/entity/synthesize/forget live in
// verbs.ts; the fifth verb is the extended `recall` op below. Spread first
// so `--surface verbs` agents see them at the top of the tool list.
// verbs.ts; the remaining three of the seven verbs (the extended `recall`,
// plus the v0.45.x boundary verbs `context_pack`/`delta`) are defined below.
// Spread first so `--surface verbs` agents see them at the top of the list.
...verbOperations,
// Page CRUD
get_page, put_page, delete_page, list_pages,
+21 -1
View File
@@ -36,6 +36,23 @@ export interface ThinkLLMClient {
create(params: Anthropic.MessageCreateParamsNonStreaming, opts?: { signal?: AbortSignal }): Promise<Anthropic.Message>;
}
/** Closed set of LLM-call failure classes carried on the wire (D6 discipline). */
export type LlmCallFailureClass = 'timeout' | 'rate_limited' | 'network' | 'provider_error';
/**
* Coarse, closed-vocabulary failure class for a thrown LLM call. The wire
* (verb `warnings`) carries ONLY this class — raw provider/transport messages
* (which can name hosts, keys, request ids) stay off remote responses and go
* to stderr instead. Exported so tests pin the vocabulary.
*/
export function classifyLlmCallFailure(e: unknown): LlmCallFailureClass {
const msg = (e instanceof Error ? e.message : String(e)).toLowerCase();
if (/\b429\b|rate.?limit|overloaded/.test(msg)) return 'rate_limited';
if (/timeout|timed.?out|etimedout/.test(msg)) return 'timeout';
if (/econnrefused|econnreset|enotfound|eai_again|network|socket|fetch failed|dns/.test(msg)) return 'network';
return 'provider_error';
}
export interface RunThinkOpts {
question: string;
/** Anchor entity slug. Activates the graph stream + entity-focused prompt. */
@@ -630,7 +647,10 @@ export async function runThink(
if ((opts.modelExplicit && e instanceof AIConfigError) || name === 'BudgetExhausted' || name === 'AbortError') {
throw e;
}
warnings.push(`LLM_CALL_FAILED: ${e instanceof Error ? e.message : String(e)}`);
// D6 closed vocabulary: the wire carries the coarse class only; the raw
// provider/transport message goes to stderr for the operator.
warnings.push(`LLM_CALL_FAILED: ${classifyLlmCallFailure(e)}`);
process.stderr.write(`[think] LLM call failed (${classifyLlmCallFailure(e)}): ${e instanceof Error ? e.message : String(e)}\n`);
synthesisStatus = 'llm_error';
synthesisOk = false;
// response keeps its empty llm_error initialization.
+1 -1
View File
@@ -592,7 +592,7 @@ export const RESPONSE_SCHEMAS: Record<VerbName, Record<string, unknown>> = {
},
pages_gathered: { type: 'integer', description: 'Pages retrieved by the gather phase behind this answer.' },
takes_gathered: { type: 'integer', description: 'Takes retrieved by the gather phase behind this answer.' },
warnings: { type: 'array', items: { type: 'string' }, description: 'Machine-stable pipeline warning codes (e.g. LLM_OUTPUT_NOT_JSON, LLM_CALL_FAILED: <detail>).' },
warnings: { type: 'array', items: { type: 'string' }, description: 'Machine-stable pipeline warning codes (e.g. LLM_OUTPUT_NOT_JSON, LLM_CALL_FAILED: <class> where <class> is timeout | rate_limited | network | provider_error).' },
},
},
forget: {
+27
View File
@@ -283,6 +283,33 @@ export function isListLevelDenialEnvelope(parsed: unknown): boolean {
return p.detail.startsWith('config_key=') || p.detail === 'fence=op';
}
/** The mcp_request_log status classes a dispatched tool result maps onto. */
export type RequestLogStatus = 'success' | 'success_with_warnings' | 'denied_after_list' | 'error';
/**
* The ONE `mcp_request_log.status` decision for a dispatched tool result
* (serve-http's tools/call persistence + SSE broadcast both consume this):
* - errors whose envelope is a list-level denial (isListLevelDenialEnvelope
* above) → 'denied_after_list' (amendment 33 / D10 trend-to-zero metric);
* other errors (including unparseable content) → 'error';
* - successes whose `_meta.warnings` is a non-empty array →
* 'success_with_warnings' (WP3 amendment 13 warn-mode observability;
* warn CONTENTS are never logged); otherwise → 'success'.
* The scope-deny and unknown-op paths in serve-http log their statuses
* directly — they never produce a ToolResult through the dispatcher.
*/
export function requestLogStatusForResult(result: ToolResult): RequestLogStatus {
if (result.isError) {
try {
const parsed: unknown = JSON.parse(result.content[0]?.text ?? '{}');
if (isListLevelDenialEnvelope(parsed)) return 'denied_after_list';
} catch { /* unparseable error content stays plain 'error' */ }
return 'error';
}
const warnings = result._meta?.warnings;
return Array.isArray(warnings) && warnings.length > 0 ? 'success_with_warnings' : 'success';
}
/**
* WP3: the ONE unknown_tool envelope builder, shared by all three deny paths
* (surface-hidden via allowedOps, nonexistent op, localOnly over a network
+5 -4
View File
@@ -44,8 +44,9 @@ export async function readPublishGate(
/**
* The set of op NAMES whose publish gate currently resolves off. tools/list
* subtracts this set. One getConfig read per DISTINCT gate key per call
* (two today), deliberately not memoized — the per-request read is what
* makes `gbrain config set mcp.publish_skills true` take effect without a
* (two today, issued concurrently — one RTT of latency, not one per key),
* deliberately not memoized — the per-request read is what makes
* `gbrain config set mcp.publish_skills true` take effect without a
* server restart.
*/
export async function disabledOpsForPublishGates(
@@ -56,9 +57,9 @@ export async function disabledOpsForPublishGates(
if (gated.length === 0) return new Set();
const keys = [...new Set(gated.map(op => op.publishGateKey as PublishGateKey))];
const resolved = new Map<PublishGateKey, boolean>();
for (const key of keys) {
await Promise.all(keys.map(async (key) => {
resolved.set(key, await readPublishGate(engine, config, key));
}
}));
const disabled = new Set<string>();
for (const op of gated) {
if (!resolved.get(op.publishGateKey as PublishGateKey)) disabled.add(op.name);
+16
View File
@@ -91,6 +91,22 @@ export const STARTER_OPS: ReadonlySet<string> = new Set([
'request_tools',
]);
/**
* The never-remove STARTER_OPS core: the seven frozen verbs, identity
* (`whoami`), discovery (`request_tools`), and the agent lane
* (`submit_agent`/`get_agent_job` — FOV-4: agent-scope clients must not be
* stranded). Usage-driven re-derivation (`scripts/derive-starter-ops.ts`)
* and the advisor drift check (collect-mcp-client-fit) both consume THIS
* set so "always included" has exactly one definition.
*/
export const ALWAYS_INCLUDED_STARTER_OPS: ReadonlySet<string> = new Set([
...VERB_NAMES,
'whoami',
'request_tools',
'submit_agent',
'get_agent_job',
]);
/** Strict flag parser — unknown values reject loudly (parseStdioIdleTimeout pattern). */
export function parseSurfaceFlag(args: string[]): McpSurface | null {
const idx = args.indexOf('--surface');
+31 -6
View File
@@ -29,7 +29,7 @@ import {
MIN_CALLS_FOR_FIT,
} from '../src/core/advisor/collect-mcp-client-fit.ts';
import { COLLECTORS } from '../src/core/advisor/run.ts';
import { STARTER_OPS } from '../src/mcp/surface.ts';
import { STARTER_OPS, ALWAYS_INCLUDED_STARTER_OPS } from '../src/mcp/surface.ts';
import { operations } from '../src/core/operations.ts';
import type { AdvisorContext } from '../src/core/advisor/types.ts';
@@ -175,10 +175,27 @@ describe('set-level drift', () => {
// busy-client made nonStarterOp a top-used op that starter lacks.
expect(drift!.detail).toContain(nonStarterOp);
// The fixture uses only two starter ops, so some starter member
// (excluding the always-included verbs/whoami/request_tools) is unused.
// (excluding ALWAYS_INCLUDED_STARTER_OPS) is unused.
expect(drift!.detail).toContain('unused for 90d');
expect(drift!.detail).toContain('derive-starter-ops');
});
test('agent-lane ops are always-included — never flagged as unused starter members', async () => {
const findings = await collectMcpClientFit.collect(ctx());
const drift = findings.find((f) => f.id === 'mcp_starter_ops_drift');
expect(drift).toBeDefined();
// No fixture client ever calls the agent lane, yet it must not read as
// drift: it is in ALWAYS_INCLUDED_STARTER_OPS by construction (pre-fix,
// the collector's local always-set omitted it → a perpetual finding).
expect(drift!.detail).not.toContain('submit_agent');
expect(drift!.detail).not.toContain('get_agent_job');
});
test('ALWAYS_INCLUDED_STARTER_OPS ⊆ STARTER_OPS (an always-member outside starter would be unfixable drift)', () => {
for (const op of ALWAYS_INCLUDED_STARTER_OPS) {
expect(STARTER_OPS.has(op)).toBe(true);
}
});
});
describe('nag snooze (escalate-then-suppress)', () => {
@@ -202,9 +219,17 @@ describe('nag snooze (escalate-then-suppress)', () => {
for (let run = 1; run <= 3; run++) await collectMcpClientFit.collect(ctx());
let findings = await collectMcpClientFit.collect(ctx());
expect(findings.map((f) => f.id)).not.toContain('mcp_starter_fit:fit-client');
// New starter-surface op joins fit-client's usage → new fingerprint.
await seedCalls('fit-client', 'request_tools', 1);
findings = await collectMcpClientFit.collect(ctx());
expect(findings.map((f) => f.id)).toContain('mcp_starter_fit:fit-client');
try {
// New starter-surface op joins fit-client's usage → new fingerprint.
await seedCalls('fit-client', 'request_tools', 1);
findings = await collectMcpClientFit.collect(ctx());
expect(findings.map((f) => f.id)).toContain('mcp_starter_fit:fit-client');
} finally {
// The shared engine outlives this test — remove the extra usage row so
// fit-client's fingerprint is unchanged for any later assertion.
await engine.executeRaw(
`DELETE FROM mcp_request_log WHERE token_name = 'fit-client' AND operation = 'request_tools'`,
);
}
});
});
+13 -1
View File
@@ -48,6 +48,9 @@ beforeAll(async () => {
// client-b: legacy bearer transport rows.
await seed('client-b', 'tools/call:query', 2);
await seed('client-b', 'tools/call:', 1); // empty op name → dropped
// Prefixed method/audit rows must drop too (the stripped name re-runs hygiene).
await seed('client-b', 'tools/call:tools/list', 2);
await seed('client-b', 'tools/call:surface_change', 1);
// client-c: automation-shaped (19 boundary calls of 20 total = 0.95).
await seed('client-c', 'context_pack', 15);
@@ -83,6 +86,13 @@ describe('normalizeLoggedOperation (hygiene rules, single source)', () => {
expect(normalizeLoggedOperation('tools/call:query')).toBe('query');
expect(normalizeLoggedOperation('tools/call:')).toBeNull();
});
test('stripped names re-run the hygiene check — prefixed method/audit rows drop', () => {
expect(normalizeLoggedOperation('tools/call:tools/list')).toBeNull();
expect(normalizeLoggedOperation('tools/call:surface_change')).toBeNull();
expect(normalizeLoggedOperation('tools/call:initialize')).toBeNull();
expect(normalizeLoggedOperation('tools/call:notifications/initialized')).toBeNull();
expect(normalizeLoggedOperation('tools/call:ping')).toBeNull();
});
});
describe('readClientOpUsage', () => {
@@ -101,7 +111,9 @@ describe('readClientOpUsage', () => {
const usage = await readClientOpUsage(engine);
const b = usage.find((u) => u.token_name === 'client-b');
expect(b).toBeDefined();
expect(b!.ops).toEqual({ query: 2 }); // 'tools/call:' with empty name dropped
// 'tools/call:' (empty name) and the prefixed method/audit rows
// ('tools/call:tools/list', 'tools/call:surface_change') all dropped.
expect(b!.ops).toEqual({ query: 2 });
expect(b!.total_calls).toBe(2);
});
+43 -10
View File
@@ -104,6 +104,19 @@ describe('request_tools catalog (no args)', () => {
expect(names).toContain('list_skills');
});
test('trusted local CLI (remote === false, no transport): localOnly + gated ops visible', async () => {
const res = await dispatchToolCall(engine, 'request_tools', {}, {
remote: false, sourceId: 'default',
});
const names = flatNames(parsed(res).catalog);
// The local operator CAN call all of these (assertPublishEnabled exempts
// remote === false), so hiding them would make the catalog dishonest.
expect(names).toContain('file_list'); // localOnly
expect(names).toContain('sync_brain'); // localOnly
expect(names).toContain('list_skills'); // publish-gated (gates pinned off above)
expect(names).toContain('advisor'); // publish-gated
});
test('read-scope token: write/admin ops hidden, reads visible', async () => {
const res = await dispatchToolCall(engine, 'request_tools', {}, {
...HTTP, auth: authFor('ro-client', ['read']),
@@ -292,16 +305,36 @@ describe('request_tools {surface} persist branch', () => {
expect(rows[0].surface).toBe(null);
});
// LAST: mutates the schema (drops the v127 columns).
test('dry-run previews never consume the persist budget (D14.5)', async () => {
await seedClient('cl-dry-budget');
const opts = { ...HTTP, surfaceCeiling: 'full' as const, auth: authFor('cl-dry-budget', ['read']) };
for (let i = 0; i < 5; i++) {
const res = await dispatchToolCall(engine, 'request_tools', { surface: 'starter', dry_run: true }, opts);
expect(parsed(res).dry_run).toBe(true);
}
// The limiter meters actual writes only: a real persist still succeeds
// after 5 previews (pre-fix, the previews exhausted the budget).
const real = await dispatchToolCall(engine, 'request_tools', { surface: 'starter' }, opts);
expect(real.isError ?? false).toBe(false);
expect(parsed(real).persisted).toBe(true);
});
// LAST: mutates the schema (drops the v127 columns); the finally block
// restores them so the shared engine stays whole for any later suite.
test('pre-migration brain: persist reports {persisted:false, reason:"migration pending"} (D5)', async () => {
await engine.executeRaw(`ALTER TABLE oauth_clients DROP COLUMN IF EXISTS surface`, []);
await engine.executeRaw(`ALTER TABLE oauth_clients DROP COLUMN IF EXISTS surface_set_by`, []);
const res = await dispatchToolCall(engine, 'request_tools', { surface: 'starter' }, {
...HTTP, surfaceCeiling: 'full', auth: authFor('cl-persist', ['read']),
});
expect(res.isError ?? false).toBe(false);
const body = parsed(res);
expect(body.persisted).toBe(false);
expect(body.reason).toBe('migration pending');
try {
await engine.executeRaw(`ALTER TABLE oauth_clients DROP COLUMN IF EXISTS surface`, []);
await engine.executeRaw(`ALTER TABLE oauth_clients DROP COLUMN IF EXISTS surface_set_by`, []);
const res = await dispatchToolCall(engine, 'request_tools', { surface: 'starter' }, {
...HTTP, surfaceCeiling: 'full', auth: authFor('cl-persist', ['read']),
});
expect(res.isError ?? false).toBe(false);
const body = parsed(res);
expect(body.persisted).toBe(false);
expect(body.reason).toBe('migration pending');
} finally {
await engine.executeRaw(`ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS surface TEXT`, []);
await engine.executeRaw(`ALTER TABLE oauth_clients ADD COLUMN IF NOT EXISTS surface_set_by TEXT`, []);
}
});
});
+19 -2
View File
@@ -32,6 +32,7 @@ import {
runThink,
persistSynthesis,
composeExtractiveFallback,
classifyLlmCallFailure,
type ThinkLLMClient,
} from '../src/core/think/index.ts';
import type { SearchResult } from '../src/core/types.ts';
@@ -147,6 +148,17 @@ describe('composeExtractiveFallback — ENG-19 never-fabricate pins', () => {
});
});
describe('classifyLlmCallFailure — D6 closed vocabulary', () => {
it('maps error shapes onto the closed set (raw detail never rides the wire)', () => {
expect(classifyLlmCallFailure(new Error('Request timed out after 60000ms'))).toBe('timeout');
expect(classifyLlmCallFailure(new Error('429 rate limited'))).toBe('rate_limited');
expect(classifyLlmCallFailure(new Error('rate_limit_error: overloaded'))).toBe('rate_limited');
expect(classifyLlmCallFailure(new Error('fetch failed: ECONNREFUSED 10.0.0.1'))).toBe('network');
expect(classifyLlmCallFailure(new Error('internal server error'))).toBe('provider_error');
expect(classifyLlmCallFailure('not even an Error')).toBe('provider_error');
});
});
describe('runThink — llm_error reachability (ENG-10: create() throws)', () => {
it('a thrown 429/network error becomes synthesis_status llm_error + LLM_CALL_FAILED warning', async () => {
await seedZephyrinePage();
@@ -160,7 +172,10 @@ describe('runThink — llm_error reachability (ENG-10: create() throws)', () =>
});
expect(result.synthesis_status).toBe('llm_error');
expect(result.synthesisOk).toBe(false);
expect(result.warnings.some(w => w.startsWith('LLM_CALL_FAILED: simulated 429'))).toBe(true);
// D6 closed vocabulary: the wire carries the coarse class, never the raw
// provider message.
expect(result.warnings).toContain('LLM_CALL_FAILED: rate_limited');
expect(result.warnings.some(w => w.includes('simulated'))).toBe(false);
expect(result.usage).toBeNull();
// Gather found the seeded page → extractive material rides the result.
expect(result.pagesGathered).toBeGreaterThanOrEqual(1);
@@ -240,7 +255,9 @@ describe('synthesize verb — compose-failure precedence (via dispatch)', () =>
);
expect(isError).toBe(false);
expect(body.synthesis_status).toBe('extractive_fallback');
expect(body.warnings.some((w: string) => w.startsWith('LLM_CALL_FAILED: simulated 503'))).toBe(true);
// '503 upstream' carries no timeout/rate-limit/network shape → provider_error.
expect(body.warnings).toContain('LLM_CALL_FAILED: provider_error');
expect(body.warnings.some((w: string) => w.includes('simulated'))).toBe(false);
expect(body.sources).toContain(SEED_SLUG);
expect(validateAgainstSchema(body, RESPONSE_SCHEMAS.synthesize)).toEqual([]);
});