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/calm-schema-proof.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Expose `resultSchemaState` on `CompiledAgentRoute` and `RouteManifestRoute` so Workbench consumers distinguish absent, unknown, and unprojectable result schemas (#691)
1 change: 1 addition & 0 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export type {
RouteContract,
RouteContractOrigin,
RouteProvenance,
RouteResultSchemaState,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the public schema-evidence export

This exposes RouteResultSchemaState through the public API and adds resultSchemaState to public route manifests, but the commit does not update either documentation locale. The handwritten architecture pages still enumerate the fields carried by CompiledAgentRoute without this field, so update the matching English and Chinese pages alongside the export.

AGENTS.md reference: AGENTS.md:L132-L138

Useful? React with 👍 / 👎.

} from './routes/types.ts';
export type { BuildResult } from './build/build.ts';
export type { PackageBuildResult, PackageOutputFile } from './build/package-build.ts';
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/contracts/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
RouteManifestProvenance,
RouteManifestProvider,
RouteManifestResponse,
RouteManifestResultSchemaState,
RouteManifestRoute,
RouteManifestServer,
RouteManifestServerMode,
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export type {
RouteManifestContract,
RouteManifestProvider,
RouteManifestResponse,
RouteManifestResultSchemaState,
RouteManifestRoute,
RouteManifestServer,
} from './routes/route-manifest.ts';
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/dev/routes/application-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
RouteManifestCliCommand,
RouteManifestConfigEntry,
RouteManifestKind,
RouteManifestResultSchemaState,
RouteManifestRoute,
} from './route-manifest.ts';
import {
Expand All @@ -31,6 +32,7 @@ export interface ApplicationLeaf {
readonly label: string;
readonly preflight?: string;
readonly ref: ApplicationNodeRef;
readonly resultSchemaState?: RouteManifestResultSchemaState;
readonly routeId?: string;
readonly source?: string;
}
Expand Down Expand Up @@ -177,6 +179,7 @@ const leafForRoute = (
label: routeLabel(ref),
...(route.execution?.preflight === undefined ? {} : { preflight: route.execution.preflight }),
ref,
...(route.resultSchemaState === undefined ? {} : { resultSchemaState: route.resultSchemaState }),
routeId: route.id,
source: route.source,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface RouteInvocation extends RouteInvocationSummary {
readonly events: readonly AgentRenderEvent[];
readonly projection: RouteInvocationProjection;
readonly providers: readonly RouteInvocationProvider[];
/** The document value parsed by the route's own `resultSchema`; absent when the module exports none or rendering failed. */
/** Structured value recorded by the selected surface; its presence alone proves neither a `resultSchema` declaration nor validation. */
readonly result?: JsonValue;
/** Event-kernel phase events emitted by a compiled preflight execution. */
readonly trace?: readonly EventTraceEvent[];
Expand Down
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/dev/routes/route-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
CompiledRouteGraph,
CompiledServerMode,
CompiledServerSurface,
RouteResultSchemaState,
} from '../../routes/types.ts';
import type {
ArtifactManifestCliCommand,
Expand Down Expand Up @@ -64,8 +65,13 @@ export type RouteManifestContract = ArtifactManifestRouteContract;
*/
export interface RouteManifestRoute extends ArtifactManifestRoute {
readonly config: readonly RouteManifestConfigEntry[];
/** Static declaration/projection evidence; absent only on manifests from older dev servers. */
readonly resultSchemaState?: RouteManifestResultSchemaState;
}

/** Result schemas execute as authored, so a declaration is known without inventing a static schema projection. */
export type RouteManifestResultSchemaState = RouteResultSchemaState;

/** One MCP server surface with the routes its packaging mode actually compiles. */
export interface RouteManifestServer {
readonly id: string;
Expand Down Expand Up @@ -158,6 +164,7 @@ const configSummary = (config: Readonly<Record<string, unknown>>): readonly Rout
const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => ({
...artifactRouteFor(route),
config: configSummary(route.config),
...(route.resultSchemaState === undefined ? {} : { resultSchemaState: route.resultSchemaState }),
});

const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ({
Expand Down
11 changes: 10 additions & 1 deletion packages/agent-bundle/src/routes/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from './config-extract.ts';
import {
discoverEventRoutePreflight,
scanRouteModuleExports,
type EventRoutePreflightDiscovery,
validateEventRouteModuleContract,
validateLayoutModuleContract,
Expand Down Expand Up @@ -67,6 +68,7 @@ import {
type CompiledServerSurface,
type RouteContract,
type RouteInputSchema,
type RouteResultSchemaState,
} from './types.ts';

type ProjectIgnoreRules = Awaited<ReturnType<typeof readProjectIgnoreRules>>;
Expand Down Expand Up @@ -573,6 +575,7 @@ const inlineContractIdOf = (route: CompiledAgentRoute): string =>
const compiledRoute = (
module: DiscoveredRouteModule,
config: Readonly<Record<string, unknown>>,
resultSchemaState: RouteResultSchemaState,
contract?: ContractBinding,
preflight?: CompiledEventPreflight,
): CompiledAgentRoute => ({
Expand All @@ -584,6 +587,7 @@ const compiledRoute = (
kind: module.kind,
...(preflight === undefined ? {} : { preflight }),
provenance: { kind: 'conventional', relativePath: module.relativePath },
resultSchemaState,
...(module.serverName === undefined ? {} : { serverId: `mcp:${module.serverName}` }),
source: module.source,
});
Expand Down Expand Up @@ -612,6 +616,7 @@ interface ExtractedModuleMetadata {
readonly inputSchema?: ExtractedInputSchema;
readonly preflight?: CompiledEventPreflight;
readonly preflightDiagnostics: readonly Diagnostic[];
readonly resultSchemaState: RouteResultSchemaState;
}

const emptyExtractedRouteConfig: ExtractedRouteConfig = deepFreeze({
Expand All @@ -627,7 +632,7 @@ const extractedModuleMetadata = (
preflightDiscovery?: EventRoutePreflightDiscovery,
): ExtractedModuleMetadata => {
if (moduleText === undefined) {
return { extracted: emptyExtractedRouteConfig, preflightDiagnostics: [] };
return { extracted: emptyExtractedRouteConfig, preflightDiagnostics: [], resultSchemaState: 'unknown' };
}
const extracted = extractRouteConfig(moduleText, module.relativePath, module.source, { projectRoot });
const inputSchema = extractInputSchema(moduleText, module.relativePath, { projectRoot, source: module.source });
Expand All @@ -648,6 +653,9 @@ const extractedModuleMetadata = (
...(inputSchema === undefined ? {} : { inputSchema }),
...(preflight === undefined ? {} : { preflight }),
preflightDiagnostics: discovery?.diagnostics ?? [],
resultSchemaState: scanRouteModuleExports(moduleText, module.relativePath, { source: module.source }).named.has('resultSchema')
? 'unprojectable'
: 'absent',
};
};

Expand Down Expand Up @@ -1019,6 +1027,7 @@ export const compileRouteGraph = async (
const route = compiledRoute(
module,
resolved.config,
metadata.resultSchemaState,
metadata.inputSchema === undefined ? undefined : contractBindings.get(contractIdOf(metadata.inputSchema.origin)),
metadata.preflight,
);
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type {
RouteContract,
RouteContractOrigin,
RouteProvenance,
RouteResultSchemaState,
} from './types.ts';
export { generateRouteTypes, routeTypesRelativePath, writeRouteTypes } from './typegen.ts';
export {
Expand Down
8 changes: 8 additions & 0 deletions packages/agent-bundle/src/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export interface RouteContract {
readonly routes: readonly string[];
}

/**
* Static evidence for a route's `resultSchema`. Result schemas are executed
* as authored and are never reduced to the bounded input-schema projection.
*/
export type RouteResultSchemaState = 'absent' | 'unknown' | 'unprojectable';

/** One conventional route module compiled into the immutable route graph. */
export interface CompiledAgentRoute {
/** Statically extracted from the module's `export const config` declaration; {@link emptyRouteConfig} when absent or rejected. */
Expand All @@ -148,6 +154,8 @@ export interface CompiledAgentRoute {
/** Static cheap gate; present only on event routes that declare a valid relative default re-export. */
readonly preflight?: CompiledEventPreflight;
readonly provenance: RouteProvenance;
/** Omitted only by legacy or manually assembled graphs, where consumers must treat the declaration as unknown. */
readonly resultSchemaState?: RouteResultSchemaState;
/** The owning MCP server id (`mcp:<name>`); MCP route kinds only. */
readonly serverId?: string;
/** Absolute route module path. */
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/tests/route-manifest-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ it('projects a compiled graph into the browser manifest with project-relative so
expect(route.source.startsWith('/')).toBe(false);
expect(route.provenance).toEqual({ kind: 'conventional' });
}
const echo = manifest.servers.flatMap((server) => server.routes)
.find((route) => route.id === 'tool:harness/echo');
expect(echo?.resultSchemaState).toBe('unprojectable');
expect(manifest.events[0]?.resultSchemaState).toBe('absent');
expect(manifest.cli?.commands).toContainEqual(expect.objectContaining({
mcp: { confirm: false, server: 'harness', tool: 'echo' },
routeId: 'tool:harness/echo',
Expand Down
64 changes: 61 additions & 3 deletions packages/workbench/src/application/route-inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,66 @@ const Rows = ({ rows }: { readonly rows: readonly InspectorRow[] }): React.React

const Empty = ({ children }: { readonly children: React.ReactNode }): React.ReactNode => <p className="inspector-empty" role="status">{children}</p>;

const resultSchemaRows = (
leaf: ApplicationLeaf,
invocation: RouteInvocation | undefined,
): readonly InspectorRow[] => {
const structuredResult = row(
'Structured result',
invocation === undefined
? 'Unavailable · the route has not run.'
: invocation.result === undefined
? 'Unavailable · this invocation recorded no structured result.'
: 'Available · open Structured result.',
);
const state = leaf.resultSchemaState ?? 'unknown';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind schema evidence to the invocation revision

When inspecting a historical or deep-linked invocation after the route has been rebuilt, this reads declaration evidence from the current ApplicationLeaf even though the invocation carries its own manifestDigest and sourceRevision. Adding or removing resultSchema can therefore make an old parsed result appear to have no declaration, or make a pre-schema invocation appear declared. Persist the declaration state with the invocation or guard this combination by matching revisions.

Useful? React with 👍 / 👎.

switch (state) {
case 'absent':
return [
row('Declaration', 'Absent · no resultSchema export was observed.'),
row('Static projection', 'Not applicable · no resultSchema is declared.'),
row('Validation / transformation', 'Not applicable · no declared resultSchema can validate or transform this result.'),
structuredResult,
];
case 'unknown':
return [
row('Declaration', 'Unknown · static declaration evidence is unavailable.'),
row('Static projection', 'Unknown · no static result-schema projection evidence is available.'),
row('Validation / transformation', 'Unknown · declaration evidence is unavailable.'),
structuredResult,
];
case 'unprojectable': {
const validatesResult = leaf.ref.kind === 'cli'
|| leaf.ref.kind === 'prompt'
|| leaf.ref.kind === 'resource'
|| leaf.ref.kind === 'tool';
const validation = !validatesResult
? 'Not applicable · this route execution contract does not apply resultSchema.'
: invocation === undefined
? 'Not run · invoke the route to observe validation or transformation.'
: invocation.status !== 'succeeded'
? 'Not recorded · the invocation did not complete with a parsed result.'
: invocation.outcome?.kind !== 'success'
? 'Not recorded · the invocation completed without a successful outcome.'
Comment on lines +149 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve validation evidence for non-success outcomes

For a successful unit-render whose application outcome is non-success, this branch incorrectly reports that schema parsing was not recorded. renderRouteEvents parses resultSchema before invocationOutcome classifies the result, so a CLI using a result-derived nonzero exit code—or an accepted represented-error document—can have a successfully parsed invocation.result while landing here. Outcome meaning should not override the positive validation evidence already established by a completed unit render with a result.

Useful? React with 👍 / 👎.

: invocation.surface.kind !== 'unit-render'
? 'Unknown · this invocation surface did not report resultSchema validation or transformation.'
: invocation.result === undefined
? 'Not recorded · execution returned no parsed resultSchema value.'
: 'Succeeded · execution recorded the value parsed by resultSchema.';
return [
row('Declaration', 'Declared · the compiler observed a resultSchema export.'),
row('Static projection', 'Unavailable · resultSchema is not statically projected.'),
row('Validation / transformation', validation),
structuredResult,
];
}
default: {
const exhaustive: never = state;
return exhaustive;
}
}
};

const SourceTab = ({ backendKind, invocation, leaf }: Pick<RouteInspectorProps, 'backendKind' | 'invocation' | 'leaf'>): React.ReactNode => <>
<Rows rows={[
...(leaf.source === undefined ? [row('Source', 'Not a route module (declared in configuration)')] : [row('Source', leaf.source)]),
Expand Down Expand Up @@ -135,9 +195,7 @@ const SchemaTab = ({ invocation, leaf }: Pick<RouteInspectorProps, 'invocation'
? <Empty>The input schema is richer than the statically projectable grammar; the editor accepts raw JSON and the route validates during execution.</Empty>
: <pre className="inspector-json"><code>{displayAgentDocumentValue(leaf.inputSchema)}</code></pre>}
<h3>Result schema</h3>
{invocation?.result === undefined
? <Empty>The result schema is not projected statically. A route that exports <code>resultSchema</code> shows its parsed value under Structured result after a run.</Empty>
: <Empty>This route exports a <code>resultSchema</code>; its parsed value is under Structured result.</Empty>}
<Rows rows={resultSchemaRows(leaf, invocation)} />
</>;

const ProvidersTab = ({ invocation }: Pick<RouteInspectorProps, 'invocation'>): React.ReactNode => {
Expand Down
1 change: 1 addition & 0 deletions packages/workbench/src/routes/route-manifest-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const routeSchema: z.ZodType<RouteManifestRoute> = z.strictObject({
inputSchema: inputSchema.optional(),
kind: z.enum(['app', 'cli', 'event-route', 'prompt', 'resource', 'script', 'tool']),
provenance: z.strictObject({ kind: z.literal('conventional') }),
resultSchemaState: z.enum(['absent', 'unknown', 'unprojectable']).optional(),
serverId: z.string().optional(),
source: z.string(),
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,11 @@ e2e('accepts the audiobook-curator Application workspace at 1440×900', { timeou
await page.getByRole('tab', { name: 'Timings' }).click();
const timingRow = page.locator('.inspector-timings li').filter({ hasText: finalTiming.phase });
await expect(timingRow).toContainText(`${String(finalTiming.durationMs)} ms`);
await page.getByRole('tab', { name: 'Schema' }).click();
const schemaPanel = page.getByRole('tabpanel', { name: 'Schema' });
await expect(schemaPanel).toContainText('Declared · the compiler observed a resultSchema export.');
await expect(schemaPanel).toContainText('Unknown · this invocation surface did not report resultSchema validation or transformation.');
await expect(schemaPanel).toContainText('Available · open Structured result.');

const epochBeforeEdit = await readBuildEpoch(page);
const markedSearch = healthySearch.replace(
Expand Down
2 changes: 2 additions & 0 deletions packages/workbench/tests/route-manifest-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ const manifest = {
},
kind: 'tool',
provenance: { kind: 'conventional' },
resultSchemaState: 'unprojectable',
serverId: 'mcp:library',
source: 'src/mcp/library/tools/echo.ts',
}],
Expand Down Expand Up @@ -142,6 +143,7 @@ it('reads the compiled manifest over the shared foreground session', async () =>
expect(decoded.servers[0]?.routes[0]?.contract).toBe(
'contract:src/lib/protocol-schemas.ts#statusInputSchema',
);
expect(decoded.servers[0]?.routes[0]?.resultSchemaState).toBe('unprojectable');
expect(decoded.events[0]?.execution).toEqual({
fallback: 'none',
preflight: 'src/events/tool/after.preflight.ts',
Expand Down
Loading
Loading