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
39 changes: 39 additions & 0 deletions scripts/run-batch-benchmark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ import { pathToFileURL } from 'node:url';
import { createHarness, rawText, isError, parseToolResponse } from '../tests/e2e/harness.js';
import type { SessionFile } from '../src/schema/session.schema.js';
import { deliveredChars } from '../src/utils/batch.js';
import { measureFanOut, fanOutRatios, fanOutLines } from '../src/utils/fan-out.js';
import { loadWorkflowWithDiagnostics } from '../src/loaders/workflow-loader.js';
import { composeActivityTechnique } from '../src/loaders/technique-loader.js';
import { flattenActivitySteps, techniqueName } from '../src/schema/activity.schema.js';
import type { Technique } from '../src/schema/technique.schema.js';
import { requireWorkflowsRoot } from './workflows-root.js';

/** The analysis run through the middle of the main workflow — the best measured batch candidate. */
export const DEFAULT_RUN = ['implementation-analysis', 'plan-prepare', 'assumptions-review'];
Expand Down Expand Up @@ -161,6 +167,33 @@ async function walk(
}
}

/**
* Compose every step-bound operation of the walked run and measure how much of each delivery is
* content declared above it (#404 W6). Reported warn-only: a container rule is meant to be
* cross-cutting, so a low reach figure describes the design rather than faulting it.
*/
async function measureRunFanOut(workflowId: string, activities: string[]) {
const root = requireWorkflowsRoot(join(import.meta.dirname, '..', 'workflows'));
const loaded = await loadWorkflowWithDiagnostics(root, workflowId);
if (!loaded.success) throw loaded.error;
const { workflow, activitySourceWorkflow } = loaded.value;

const composed: Technique[] = [];
for (const activityId of activities) {
const activity = workflow.activities?.find((a) => a.id === activityId);
if (!activity) continue;
const scopeWorkflowId = activitySourceWorkflow.get(activityId) ?? workflowId;
for (const step of flattenActivitySteps(activity)) {
if (step.kind !== 'technique') continue;
const ref = techniqueName(step.technique);
if (!ref) continue;
const result = await composeActivityTechnique(ref, root, scopeWorkflowId, activityId);
if (result.success) composed.push(result.value.technique);
}
}
return measureFanOut(composed);
}

/** Walk one pass `repeat` times and keep the best elapsed, so a cold FS cache does not dominate. */
export async function measure(
mode: PassMetrics['mode'],
Expand All @@ -184,6 +217,7 @@ async function main(): Promise<number> {

const perActivity = await measure('per-activity', { workflowId, activities, contextTokens, repeat });
const batched = await measure('batched', { workflowId, activities, contextTokens, repeat });
const fanOut = await measureRunFanOut(workflowId, activities);

const charSavingPct = perActivity.deliveredChars === 0
? 0
Expand Down Expand Up @@ -222,6 +256,10 @@ async function main(): Promise<number> {
spawnSecondsInput: spawnSeconds,
runDurationSavingSeconds: Number((dispatchesAvoided * spawnSeconds).toFixed(1)),
},
// Warn-only, and nothing gates on it. Container rules and inherited I/O are cross-cutting by
// design, so a reach figure describes what the corpus intends rather than faulting it; the
// figures are here so the fan-out is visible and a regression is arguable.
fanOut: { ...fanOut, ...fanOutRatios(fanOut) },
};
process.stdout.write(JSON.stringify(report, null, 2) + '\n');

Expand All @@ -236,6 +274,7 @@ async function main(): Promise<number> {
` projected run duration: ${report.projected.runDurationSavingSeconds}s saved, from `
+ `${report.projected.basis} — supply your harness's own --spawn-seconds to re-base it\n`,
);
for (const line of fanOutLines(fanOut)) process.stderr.write(`${line}\n`);

if (has('gate') && charSavingPct < minSavingPct) {
process.stderr.write(`GATE FAIL: batched saving ${report.measured.charSavingPct}% is below ${minSavingPct}%\n`);
Expand Down
35 changes: 30 additions & 5 deletions src/tools/resource-tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { normalizeRepoPath, presentPathToAgent, type ServerConfig } from '../config.js';
import { withAuditLog } from '../logging.js';
import { withAuditLog, logInfo } from '../logging.js';

import { loadWorkflow, loadWorkflowWithDiagnostics, getActivity } from '../loaders/workflow-loader.js';
import { readResourceStructured } from '../loaders/resource-loader.js';
Expand Down Expand Up @@ -45,7 +45,7 @@ import {
type SessionFile,
} from '../schema/session.schema.js';
import { techniqueName, flattenActivitySteps, type Step } from '../schema/activity.schema.js';
import { buildProvenanceContext, decorateTechniqueProvenance } from '../utils/binding-provenance.js';
import { buildProducerIndex, provenanceContextFor, decorateTechniqueProvenance } from '../utils/binding-provenance.js';
import { seedDefaults } from '../utils/variable-seed.js';
import { buildValidation, validateWorkflowVersion } from '../utils/validation.js';
import { stringifyForResponse } from '../utils/serialization.js';
Expand Down Expand Up @@ -686,14 +686,15 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
// collapsing under reference delivery.
let technique = composed.value.technique;
const provenanceWarnings: string[] = [];
let resolvedTechniques = 0;
if (boundStep?.id && state.currentActivity) {
const ctx = await buildProvenanceContext({
const producerIndex = await buildProducerIndex({
workflow: wfResult.value,
workflowDir: config.workflowDir,
currentActivityId: state.currentActivity,
currentStepId: boundStep.id,
activitySourceWorkflow: wfDiag.value.activitySourceWorkflow,
});
resolvedTechniques = producerIndex.resolvedTechniques;
const ctx = provenanceContextFor(producerIndex, state.currentActivity, boundStep.id);
if (ctx) {
const binding = boundStep.kind === 'technique' && typeof boundStep.technique === 'object'
? boundStep.technique
Expand Down Expand Up @@ -769,6 +770,11 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
});
await saveSessionForTool(loaded, next);

logInfo('Technique delivery cost', {
session_index, technique: techniqueId, agentId: scope, delivery: 'unchanged',
resolved_techniques: resolvedTechniques, composed_chars: text.length, response_chars: 0,
});

// Canonical unchanged-marker: { delivery: 'unchanged', content_hash } —
// the same shape the get_activity bundle path emits (delivery.ts#unchangedMarker).
// The technique id and note ride alongside as sibling context.
Expand Down Expand Up @@ -800,6 +806,15 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
});
await saveSessionForTool(loaded, next);

// What this fetch cost to build and to send. `resolved_techniques` is the distinct bound ops the
// producer scan read to decorate one step, which is the resolve work a lazy fetch pays; the two
// character figures are the composed technique and what the response carried after any shared
// block collapsed.
logInfo('Technique delivery cost', {
session_index, technique: techniqueId, agentId: scope, delivery: 'full',
resolved_techniques: resolvedTechniques, composed_chars: text.length, response_chars: body.length,
});

return {
content: [{ type: 'text' as const, text: `session_index: ${session_index}\n\n${body}` }],
_meta: { session_index, validation },
Expand Down Expand Up @@ -891,6 +906,11 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
});
await saveSessionForTool(loaded, next);

logInfo('Resource delivery cost', {
session_index, resource: resource_id, agentId: scope, delivery: 'unchanged',
resource_chars: fullText.length, response_chars: 0,
});

const stub = stringifyForResponse({
resource_id,
...unchangedMarker(hash),
Expand All @@ -909,6 +929,11 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
});
await saveSessionForTool(loaded, next);

logInfo('Resource delivery cost', {
session_index, resource: resource_id, agentId: scope, delivery: 'full',
resource_chars: fullText.length, response_chars: fullText.length,
});

return {
content: [{ type: 'text' as const, text: fullText }],
_meta: { session_index, validation },
Expand Down
71 changes: 57 additions & 14 deletions src/tools/workflow-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ import { resolveTechniques, formatTechniqueBundle, composeActivityTechnique, pro
import { CORE_ORCHESTRATOR_TECHNIQUES, CORE_WORKER_TECHNIQUES } from '../loaders/core-ops.js';
import { readResourceRaw } from '../loaders/resource-loader.js';
import { injectResolvedStepIds, techniqueName, flattenActivitySteps, type Activity, type Step } from '../schema/activity.schema.js';
import { buildProvenanceContext, decorateTechniqueProvenance } from '../utils/binding-provenance.js';
import { withAuditLog, logWarn } from '../logging.js';
import { buildProducerIndex, provenanceContextFor, decorateTechniqueProvenance } from '../utils/binding-provenance.js';
import { withAuditLog, logInfo, logWarn } from '../logging.js';
import { applyVariableWrites } from '../utils/variable-seed.js';
import { stringifyForResponse } from '../utils/serialization.js';
import { contentHash, deliveredHash, dedupTechniqueBlocks, deliveryScope, recordDeliveries, unchangedMarker } from '../utils/delivery.js';
Expand Down Expand Up @@ -394,7 +394,11 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
if (bootstrapResult.success) {
lines.push('', bootstrapResult.value.content);
}
return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
const text = lines.join('\n');
// The first content of a session, and fixed: the same characters every run. It reports on the
// same channel as every other delivery so the bootstrap window is summable from the log alone.
logInfo('Bootstrap delivery cost', { delivery: 'full', response_chars: text.length });
return { content: [{ type: 'text' as const, text }] };
}));

server.tool('list_workflows', 'List available workflows (id, title, version, tags). On load failures returns `{workflows, load_errors}`. No session_index required.', {},
Expand Down Expand Up @@ -492,6 +496,17 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
planning_folder_path: presentPlanningPath(loaded.folderAbsPath) ?? loaded.folderAbsPath,
};

// What the orchestrator's own delivery cost. This is the largest fixed payload of a session —
// the same operations bundle every run, read before the first decision — so it reports on the
// same channel as the worker-facing deliveries rather than being the one call that says nothing.
logInfo('Workflow delivery cost', {
session_index, workflow: workflow_id, agentId: state.agentId,
delivery: opsBlock === opsText ? 'full' : 'unchanged',
resolved_techniques: orchestratorTechniques.length,
bundle_chars: opsText.length,
response_chars: preamble.length + stringifyForResponse(summaryData).length,
});

return {
content: [{ type: 'text' as const, text: preamble + stringifyForResponse(summaryData) }],
_meta: { session_index, validation },
Expand Down Expand Up @@ -909,6 +924,16 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
const headroomFraction = config.bundleHeadroomFraction ?? DEFAULT_BUNDLE_HEADROOM_FRACTION;
const charsPerToken = config.bundleCharsPerToken ?? DEFAULT_BUNDLE_CHARS_PER_TOKEN;
const eagerBudgetChars = context_tokens * headroomFraction * charsPerToken;
// Provenance resolve work, done once for the whole delivery. The producer scan reads every
// bound op in the workflow to learn its declared outputs, and its answer does not vary with
// the step being decorated — only the step's document-order position does. One index therefore
// serves every inlined step, so a delivery resolves each unique technique once however many
// steps it carries.
let producerIndex: Awaited<ReturnType<typeof buildProducerIndex>> | undefined;
// Running total of full-content characters committed to the eager bundle. An unchanged-reference
// marker costs effectively nothing, so it never draws down the budget; only full-content
// entries do. Held here so the delivery's cost line can report it against the budget.
let spentChars = 0;
if (!optedOut && result.success && activity) {
const eligible: Array<Step & { kind: 'technique' }> = [];
const collectUngated = (steps: Step[] | undefined): void => {
Expand All @@ -920,10 +945,14 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
};
collectUngated((activity as Activity).steps);

// Running total of full-content characters already committed to the eager bundle. An
// unchanged-reference marker costs effectively nothing, so it never draws down the budget;
// only full-content entries do.
let spentChars = 0;
if (eligible.length > 0) {
producerIndex = await buildProducerIndex({
workflow: result.value,
workflowDir: config.workflowDir,
activitySourceWorkflow,
});
}

for (const step of eligible) {
const ref = techniqueName(step.technique);
if (!ref) continue;
Expand All @@ -936,13 +965,9 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
const { techniqueId } = composedStep.value;
let technique = composedStep.value.technique;
let provenanceWarnings: string[] = [];
const ctx = await buildProvenanceContext({
workflow: result.value,
workflowDir: config.workflowDir,
currentActivityId: activity_id,
currentStepId: step.id!,
activitySourceWorkflow,
});
const ctx = producerIndex
? provenanceContextFor(producerIndex, activity_id, step.id!)
: null;
if (ctx) {
const binding = typeof step.technique === 'object' ? step.technique : undefined;
const decorated = decorateTechniqueProvenance(technique, ctx, binding, techniqueId, step.id!);
Expand Down Expand Up @@ -1248,6 +1273,24 @@ export function registerWorkflowTools(server: McpServer, config: ServerConfig):
may_continue: stand.mayContinue,
};

// What this delivery cost to build and to send, on one line. `resolved_techniques` is the
// distinct bound ops the producer scan read for the whole request and `provenance_passes` the
// steps decorated from that one scan, so the two together say whether resolve work is being
// repeated. `spent_chars` against `eager_budget_chars` is what the bundle drew down; the
// response length is what actually went over the wire, which is larger by the activity body
// and smaller than the sum of everything named where content collapsed to markers.
logInfo('Activity delivery cost', {
session_index, activity: activity_id, agentId: scope, delivery: referenceMode ? 'reference' : 'full',
resolved_techniques: producerIndex?.resolvedTechniques ?? 0,
provenance_passes: bundledSteps.length,
bundled_steps: bundledSteps.length,
bundled_steps_collapsed: bundledSteps.filter((b) => b.delivery === 'unchanged').length,
bundled_resources: bundledResourceDeliveries.length,
spent_chars: spentChars,
eager_budget_chars: Math.floor(eagerBudgetChars),
response_chars: responseText.length,
});

return {
content: [{ type: 'text' as const, text: responseText }],
_meta: {
Expand Down
Loading
Loading