From b15498810ea7cced8071930e3f2a468aa26fdddb Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 11 Jun 2026 12:06:29 -0400 Subject: [PATCH 1/4] Add agent-driven Summon broker --- apps/server/src/generate-route.test.ts | 49 +- apps/server/src/main.ts | 62 +- docs/adoption/integration.md | 25 + docs/adoption/package-consumption.md | 27 + packages/server/src/agent-broker.ts | 741 ++++++++++++++++++++++ packages/server/src/index.ts | 26 + packages/server/test/agent-broker.test.ts | 168 +++++ packages/summon-server/src/index.ts | 21 + scripts/build-public-packages.mjs | 21 + 9 files changed, 1132 insertions(+), 8 deletions(-) create mode 100644 packages/server/src/agent-broker.ts create mode 100644 packages/server/test/agent-broker.test.ts diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index 0108bdd..e0c5fa5 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -239,6 +239,49 @@ test('api generate sends narrowed contract and stream meta shape through package assert.deepEqual(policyContract.tools?.map((tool) => tool.name), ['search']); assert.deepEqual(policyContract.components, []); + const agentResponse = await fetch(`http://127.0.0.1:${appPort}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + prompt: 'build a dinner finder where i can search recipes', + capabilities: searchCapability, + agent: { enabled: true, intentModel: 'off' }, + }), + }); + const agentBody = await agentResponse.text(); + assert.equal(agentResponse.status, 200, agentBody); + + assert.equal(anthropicRequests.length, 3); + const agentRequest = anthropicRequests[2] as { system?: Array<{ text?: string }>; stream?: boolean }; + assert.equal(agentRequest.stream, true); + const agentSystemText = agentRequest.system?.map((block) => block.text ?? '').join('\n') ?? ''; + assert.match(agentSystemText, /Search host-owned dinner data/); + assert.match(agentSystemText, /Surface contract/); + assert.match(agentSystemText, /runtime=`declarative`/); + + const agentLines = agentBody + .trim() + .split(/\n/) + .filter(Boolean) + .map((raw) => JSON.parse(raw) as ProtocolLine); + assert.deepEqual(agentLines.slice(0, 6).map((line) => `${line.op} ${line.path}`), [ + 'meta /agent-intent', + 'meta /agent-policy-resolution', + 'meta /mode-upgraded', + 'meta /surface-policy', + 'meta /surface-plan', + 'meta /surface-contract', + ]); + const agentIntent = agentLines[0] as Extract; + assert.equal((agentIntent.value as { interaction?: unknown }).interaction, 'search'); + const agentPolicy = agentLines[3] as Extract; + assert.deepEqual(agentPolicy.value, { + tier: 'declarative', + purpose: 'explore', + grants: ['search'], + components: [], + persistence: 'replayable', + }); const ghostResponse = await fetch(`http://127.0.0.1:${appPort}/api/generate`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -257,8 +300,8 @@ test('api generate sends narrowed contract and stream meta shape through package const ghostBody = await ghostResponse.text(); assert.equal(ghostResponse.status, 200, ghostBody); - assert.equal(anthropicRequests.length, 3); - const ghostRequest = anthropicRequests[2] as { system?: Array<{ text?: string }>; stream?: boolean }; + assert.equal(anthropicRequests.length, 4); + const ghostRequest = anthropicRequests[3] as { system?: Array<{ text?: string }>; stream?: boolean }; const ghostSystemText = ghostRequest.system?.map((block) => block.text ?? '').join('\n') ?? ''; assert.match(ghostSystemText, /Checkout product experience/); @@ -296,7 +339,7 @@ test('api generate sends narrowed contract and stream meta shape through package const ghostOverrideBody = await ghostOverrideResponse.text(); assert.equal(ghostOverrideResponse.status, 400); assert.match(ghostOverrideBody, /tokenOverrides are not supported with Ghost product memory/); - assert.equal(anthropicRequests.length, 3); + assert.equal(anthropicRequests.length, 4); }); test('api generate emits compact Ghost capsule for root contexts', async (t) => { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index fcaea58..7051fec 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -11,9 +11,11 @@ import { type TokenOverride, } from '@anarchitecture/summon/engine'; import { + planAgentSurface, resolveSurfaceGenerationPlan, runSurfaceGeneration, summarizeContractIssues, + type AgentSurfacePlanResult, type GenerateEditInput, type RepairOptions as SurfaceRepairOptions, type SurfaceGenerationSummary, @@ -466,6 +468,11 @@ app.post('/api/generate', async (req, res) => { } const edit = parsedEdit.edit; const repairOptions = parseRepairOptions(req.body?.repair); + const rawAgentOptions = req.body?.agent; + const agentOptions = rawAgentOptions && typeof rawAgentOptions === 'object' + ? rawAgentOptions as Record + : null; + const agentPlanningEnabled = agentOptions?.enabled === true; const hasSurfacePolicy = req.body?.surfacePolicy !== undefined && req.body.surfacePolicy !== null; @@ -481,6 +488,7 @@ app.post('/api/generate', async (req, res) => { let modeUpgraded = false; let inferenceUsed = false; let surfacePlan: SurfacePlan; + let agentPlan: AgentSurfacePlanResult | null = null; // Shape classification — picks ONE response shape so the per-direction // block ships only the matching shape exemplar (atoms always ship). Falls @@ -504,6 +512,23 @@ app.post('/api/generate', async (req, res) => { scriptPolicy = compiledPolicy.scriptPolicy; pack = compiledPolicy.capabilities; surfacePlan = compiledPolicy.surfacePlan; + } else if (agentPlanningEnabled) { + agentPlan = await planAgentSurface({ + prompt, + capabilities: capabilityCeiling, + components: componentPack, + intentModel: process.env.SUMMON_AGENT_INTENT_MODEL === '0' || agentOptions?.intentModel === 'off' + ? null + : { + completeText: (request) => modelProvider.completeText(request, modelSelection), + }, + intentTimeoutMs: clampInt(agentOptions?.intentTimeoutMs, 250, 5000, 1800), + }); + mode = agentPlan.compiledPolicy.mode; + scriptPolicy = agentPlan.compiledPolicy.scriptPolicy; + pack = agentPlan.compiledPolicy.capabilities; + surfacePlan = agentPlan.compiledPolicy.surfacePlan; + modeUpgraded = requestedMode === 'static' && mode === 'interactive'; } else { // Layer 3: utility-model capability inference. Decides mode + narrows the // pack to the minimal subset of intents the prompt actually needs. The @@ -562,7 +587,11 @@ app.post('/api/generate', async (req, res) => { mode, surfacePlan, shape, - capabilities: hasSurfacePolicy ? capabilityCeiling : pack, + capabilities: hasSurfacePolicy + ? capabilityCeiling + : agentPlan + ? agentPlan.compiledPolicy.capabilities + : pack, components: componentPack, }); } @@ -592,6 +621,25 @@ app.post('/api/generate', async (req, res) => { }); } } + if (agentPlan) { + preludeLines.push({ + op: 'meta', + path: '/agent-intent', + value: agentPlan.intent, + }); + preludeLines.push({ + op: 'meta', + path: '/agent-policy-resolution', + value: { + source: agentPlan.policyResolution.source, + proposedSurfacePolicy: agentPlan.policyResolution.proposedSurfacePolicy, + surfacePolicy: agentPlan.policyResolution.surfacePolicy, + rejectedCapabilities: agentPlan.policyResolution.rejectedCapabilities, + rejectedComponents: agentPlan.policyResolution.rejectedComponents, + fallback: agentPlan.policyResolution.fallback, + }, + }); + } // Emit the mode-upgrade signal as the first byte the client sees, before // any concurrency wait. The client respawns its sandbox into interactive @@ -653,11 +701,15 @@ app.post('/api/generate', async (req, res) => { ghost: ghostContext ?? null, layout, edit, - capabilities: hasSurfacePolicy ? capabilityCeiling : pack, + capabilities: hasSurfacePolicy || agentPlan ? capabilityCeiling : pack, components: componentPack, - surfacePolicy: hasSurfacePolicy ? req.body.surfacePolicy : null, - scriptPolicy: hasSurfacePolicy ? undefined : scriptPolicy, - surfacePlan: hasSurfacePolicy ? null : surfacePlan, + surfacePolicy: hasSurfacePolicy + ? req.body.surfacePolicy + : agentPlan + ? agentPlan.surfacePolicy + : null, + scriptPolicy: hasSurfacePolicy || agentPlan ? undefined : scriptPolicy, + surfacePlan: hasSurfacePolicy || agentPlan ? null : surfacePlan, tokenOverrides: overrides.applied, activeTokensCss: ghostContext?.tokenSource.css ?? direction?.tokensCss ?? null, preludeLines, diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md index 29e8832..f5e5170 100644 --- a/docs/adoption/integration.md +++ b/docs/adoption/integration.md @@ -188,6 +188,31 @@ Common configs: Hosts choose the config before generation. The model may react to the compiled safety details, but it cannot widen what the host allowed. +### Agent-Driven Configs + +When a user is talking to an agent or another harness, the user should not need +to choose Summon tiers, grants, script policy, or surface plans. Use the server +broker to translate the prompt into a bounded host-owned config: + +```ts +import { runAgentSurfaceGeneration } from '@anarchitecture/summon-server'; + +await runAgentSurfaceGeneration({ + prompt, + modelProvider, + capabilities: capabilityContract.pack, + components: componentContract.pack, + hostPolicyResolver: ({ proposedSurfacePolicy }) => { + return productPolicy.narrow(proposedSurfacePolicy); + }, +}, emit); +``` + +The broker emits `/agent-intent` and `/agent-policy-resolution` diagnostics, +then generation continues through the normal `/surface-policy`, +`/surface-plan`, and `/surface-contract` path. The inferred intent is advisory: +the host resolver and Summon policy compiler still own authority. + ### Surface Contract View When a server receives a `SurfacePolicy`, Summon also derives a read-only diff --git a/docs/adoption/package-consumption.md b/docs/adoption/package-consumption.md index f28c574..6be0057 100644 --- a/docs/adoption/package-consumption.md +++ b/docs/adoption/package-consumption.md @@ -195,6 +195,7 @@ await consumeSurfaceStream(response.body!, { ```ts import { + runAgentSurfaceGeneration, runSurfaceGeneration, type SummonModelProvider, } from '@anarchitecture/summon-server'; @@ -209,6 +210,32 @@ accepted Summon lines and diagnostics, and returns a replay summary. consume an async generator, but new servers should prefer `runSurfaceGeneration(input, emit)`. +For agent-driven hosts, use `runAgentSurfaceGeneration(input, emit)` when the +end user should not choose Summon-specific configs. The harness supplies the +prompt, model provider, host tool catalog, trusted component catalog, and any +host policy resolver. The broker converts the prompt to an advisory +`SurfaceIntent`, proposes a `SurfacePolicy`, narrows it through host-owned +policy, then calls the same `runSurfaceGeneration()` lifecycle. + +```ts +await runAgentSurfaceGeneration({ + prompt, + modelProvider, + capabilities: capabilityContract.pack, + components: componentContract.pack, + hostPolicyResolver: ({ proposedSurfacePolicy }) => { + return productPolicy.narrow(proposedSurfacePolicy); + }, +}, (line) => { + response.write(`${JSON.stringify(line)}\n`); +}); +``` + +The intent converter can use rules, a model-assisted `intentModel`, or a custom +`intentProvider`. Its output is never authority. The host resolver and +`compileSurfacePolicy()` still decide which host tools, components, runtime, +and approval paths are actually available. + ## Package Gate Run this before publishing: diff --git a/packages/server/src/agent-broker.ts b/packages/server/src/agent-broker.ts new file mode 100644 index 0000000..08e27ac --- /dev/null +++ b/packages/server/src/agent-broker.ts @@ -0,0 +1,741 @@ +import { + compileSurfacePolicy, + SURFACE_PURPOSE_VALUES, + type CapabilityPack, + type ComponentPack, + type ComponentSpec, + type CompiledSurfacePolicy, + type IntentSpec, + type ProtocolLine, + type SurfacePersistence, + type SurfacePolicy, + type SurfacePurpose, +} from '@summon-internal/engine'; +import { runSurfaceGeneration } from './runner.js'; +import type { + SurfaceGenerationInput, + SurfaceGenerationSummary, +} from './types.js'; + +export type SurfaceIntentInteraction = + | 'none' + | 'select' + | 'form' + | 'search' + | 'background' + | 'approval'; + +export type SurfaceIntentDataNeed = 'embedded' | 'host-resource' | 'worker'; +export type SurfaceIntentSideEffect = + | 'none' + | 'local-state' + | 'external-action' + | 'approval-required'; + +export interface SurfaceIntent { + purpose: SurfacePurpose; + interaction: SurfaceIntentInteraction; + dataNeed: SurfaceIntentDataNeed; + sideEffect: SurfaceIntentSideEffect; + requestedCapabilities: string[]; + requestedComponents: string[]; + confidence: number; + rationale?: string; +} + +export interface AgentIntentRequest { + prompt: string; + capabilities?: CapabilityPack | null; + components?: ComponentPack | null; + deterministicIntent: SurfaceIntent; + signal?: AbortSignal; +} + +export type AgentIntentProvider = ( + request: AgentIntentRequest, +) => SurfaceIntent | null | Promise; + +export interface AgentIntentTextRequest { + system: string; + prompt: string; + maxTokens: number; + temperature?: number; + signal?: AbortSignal; +} + +export interface AgentIntentTextClient { + completeText(request: AgentIntentTextRequest): string | Promise; +} + +export interface HostPolicyResolutionRequest { + prompt: string; + intent: SurfaceIntent; + proposedSurfacePolicy: SurfacePolicy; + capabilities?: CapabilityPack | null; + components?: ComponentPack | null; +} + +export type HostPolicyResolver = ( + request: HostPolicyResolutionRequest, +) => SurfacePolicy | null | Promise; + +export interface AgentPolicyResolution { + source: 'default' | 'host'; + proposedSurfacePolicy: SurfacePolicy; + surfacePolicy: SurfacePolicy; + rejectedCapabilities: string[]; + rejectedComponents: string[]; + fallback: boolean; +} + +export interface AgentSurfacePlanningOptions { + intent?: SurfaceIntent | null; + intentProvider?: AgentIntentProvider | null; + intentModel?: AgentIntentTextClient | null; + intentTimeoutMs?: number; + hostPolicyResolver?: HostPolicyResolver | null; + persistence?: SurfacePersistence; + signal?: AbortSignal; +} + +export interface AgentSurfacePlanningInput extends AgentSurfacePlanningOptions { + prompt: string; + capabilities?: CapabilityPack | null; + components?: ComponentPack | null; +} + +export interface AgentSurfacePlanResult { + intent: SurfaceIntent; + proposedSurfacePolicy: SurfacePolicy; + surfacePolicy: SurfacePolicy; + compiledPolicy: CompiledSurfacePolicy; + policyResolution: AgentPolicyResolution; +} + +export type AgentSurfaceGenerationInput = Omit< + SurfaceGenerationInput, + 'surfacePolicy' | 'mode' | 'scriptPolicy' | 'surfacePlan' +> & AgentSurfacePlanningOptions & { + emitAgentDiagnostics?: boolean; +}; + +export interface AgentSurfaceGenerationSummary extends SurfaceGenerationSummary { + agent: AgentSurfacePlanResult; +} + +const PURPOSES = new Set(SURFACE_PURPOSE_VALUES); +const MIN_MODEL_CONFIDENCE = 0.45; + +const APPROVAL_RE = + /\b(approve|approval|confirm|publish|send|email|message|post|delete|remove|archive|update|change|commit|merge|deploy|invite|charge|pay|purchase|refund)\b/i; +const BACKGROUND_RE = + /\b(analy[sz]e|analysis|calculate|compute|forecast|simulate|score|rank|audit|batch|background|long[-\s]?running)\b/i; +const SEARCH_RE = + /\b(search|lookup|look\s+up|fetch|load|find|browse|filter|query|explore|discover)\b/i; +const FORM_RE = + /\b(form|collect|intake|survey|questionnaire|submit|capture|enter|input)\b/i; +const SELECT_RE = + /\b(pick|choose|select|save|remember|vote|rate|rank|favorite)\b/i; + +export async function planAgentSurface( + input: AgentSurfacePlanningInput, +): Promise { + const deterministicIntent = inferSurfaceIntent(input.prompt, { + capabilities: input.capabilities ?? null, + components: input.components ?? null, + }); + const inferredIntent = input.intent + ? normalizeSurfaceIntent(input.intent, deterministicIntent) + : await inferProvidedIntent(input, deterministicIntent); + const intent = sanitizeSurfaceIntent(inferredIntent ?? deterministicIntent, { + capabilities: input.capabilities ?? null, + components: input.components ?? null, + fallback: deterministicIntent, + }); + const proposedSurfacePolicy = policyFromIntent(intent, { + persistence: input.persistence, + }); + const policyResolution = await resolveHostPolicy({ + prompt: input.prompt, + intent, + proposedSurfacePolicy, + capabilities: input.capabilities ?? null, + components: input.components ?? null, + resolver: input.hostPolicyResolver ?? null, + }); + const compiledPolicy = compileSurfacePolicy(policyResolution.surfacePolicy, { + capabilities: input.capabilities ?? null, + components: input.components ?? null, + }); + return { + intent, + proposedSurfacePolicy, + surfacePolicy: policyResolution.surfacePolicy, + compiledPolicy, + policyResolution, + }; +} + +export async function runAgentSurfaceGeneration( + input: AgentSurfaceGenerationInput, + emit: (line: ProtocolLine) => void | Promise, +): Promise { + const agent = await planAgentSurface(input); + const preludeLines = [ + ...(input.emitAgentDiagnostics === false ? [] : agentPreludeLines(agent)), + ...(input.preludeLines ?? []), + ]; + const summary = await runSurfaceGeneration({ + ...input, + capabilities: input.capabilities ?? null, + components: input.components ?? null, + surfacePolicy: agent.surfacePolicy, + preludeLines, + }, emit); + return { + ...summary, + agent, + }; +} + +export function inferSurfaceIntent( + prompt: string, + options: { + capabilities?: CapabilityPack | null; + components?: ComponentPack | null; + } = {}, +): SurfaceIntent { + const requestedCapabilities = inferCapabilityNames(prompt, options.capabilities ?? null); + const requestedComponents = inferComponentNames(prompt, options.components ?? null); + const selectedIntents = intentsByName(options.capabilities, requestedCapabilities); + + const hasApproval = selectedIntents.some((intent) => intentAuthority(intent) === 'approval-gated'); + const hasWorker = selectedIntents.some((intent) => intentData(intent) === 'worker'); + const hasResource = selectedIntents.some((intent) => intentData(intent) === 'host-resource'); + const hasAction = selectedIntents.some((intent) => intentAuthority(intent) === 'host-action'); + + let interaction: SurfaceIntentInteraction = 'none'; + if (hasApproval || APPROVAL_RE.test(prompt)) interaction = 'approval'; + else if (hasWorker || BACKGROUND_RE.test(prompt)) interaction = 'background'; + else if (hasResource || SEARCH_RE.test(prompt)) interaction = 'search'; + else if (FORM_RE.test(prompt)) interaction = 'form'; + else if (hasAction || SELECT_RE.test(prompt)) interaction = 'select'; + + const sideEffect: SurfaceIntentSideEffect = interaction === 'approval' + ? 'approval-required' + : hasAction || interaction === 'select' || interaction === 'form' + ? 'local-state' + : 'none'; + const dataNeed: SurfaceIntentDataNeed = interaction === 'background' || hasWorker + ? 'worker' + : interaction === 'search' || hasResource + ? 'host-resource' + : 'embedded'; + + return { + purpose: inferPurpose(prompt), + interaction, + dataNeed, + sideEffect, + requestedCapabilities, + requestedComponents, + confidence: requestedCapabilities.length > 0 || interaction !== 'none' ? 0.72 : 0.58, + rationale: 'deterministic keyword and catalog match', + }; +} + +export function policyFromIntent( + intent: SurfaceIntent, + options: { persistence?: SurfacePersistence } = {}, +): SurfacePolicy { + const persistence = options.persistence ?? 'replayable'; + if (intent.sideEffect === 'approval-required' || intent.interaction === 'approval') { + return { + tier: 'approval', + purpose: 'operate', + grants: intent.requestedCapabilities, + components: intent.requestedComponents, + persistence, + }; + } + if (intent.dataNeed === 'worker' || intent.interaction === 'background') { + return { + tier: 'worker', + purpose: intent.purpose, + grants: intent.requestedCapabilities, + components: intent.requestedComponents, + persistence, + }; + } + if ( + intent.interaction !== 'none' || + intent.dataNeed === 'host-resource' || + intent.sideEffect === 'local-state' || + intent.sideEffect === 'external-action' + ) { + return { + tier: 'declarative', + purpose: intent.purpose, + grants: intent.requestedCapabilities, + components: intent.requestedComponents, + persistence, + }; + } + return { + tier: 'static', + purpose: intent.purpose, + components: intent.requestedComponents, + persistence, + }; +} + +export function defaultHostPolicyResolver( + request: HostPolicyResolutionRequest, +): SurfacePolicy { + return narrowSurfacePolicy(request.proposedSurfacePolicy, { + capabilities: request.capabilities ?? null, + components: request.components ?? null, + }).surfacePolicy; +} + +function agentPreludeLines(agent: AgentSurfacePlanResult): ProtocolLine[] { + return [ + { op: 'meta', path: '/agent-intent', value: agent.intent }, + { + op: 'meta', + path: '/agent-policy-resolution', + value: { + source: agent.policyResolution.source, + proposedSurfacePolicy: agent.policyResolution.proposedSurfacePolicy, + surfacePolicy: agent.policyResolution.surfacePolicy, + rejectedCapabilities: agent.policyResolution.rejectedCapabilities, + rejectedComponents: agent.policyResolution.rejectedComponents, + fallback: agent.policyResolution.fallback, + }, + }, + ]; +} + +async function inferProvidedIntent( + input: AgentSurfacePlanningInput, + deterministicIntent: SurfaceIntent, +): Promise { + if (input.intentProvider) { + return input.intentProvider({ + prompt: input.prompt, + capabilities: input.capabilities ?? null, + components: input.components ?? null, + deterministicIntent, + signal: input.signal, + }); + } + if (!input.intentModel) return null; + return inferIntentWithModel(input.intentModel, { + prompt: input.prompt, + capabilities: input.capabilities ?? null, + components: input.components ?? null, + deterministicIntent, + timeoutMs: input.intentTimeoutMs, + signal: input.signal, + }); +} + +async function inferIntentWithModel( + client: AgentIntentTextClient, + input: { + prompt: string; + capabilities?: CapabilityPack | null; + components?: ComponentPack | null; + deterministicIntent: SurfaceIntent; + timeoutMs?: number; + signal?: AbortSignal; + }, +): Promise { + const system = buildIntentClassifierPrompt(input.capabilities ?? null, input.components ?? null); + try { + const request = client.completeText({ + system, + prompt: input.prompt, + maxTokens: 500, + temperature: 0, + signal: input.signal, + }); + const raw = await Promise.race([ + Promise.resolve(request), + new Promise((resolve) => setTimeout(() => resolve(null), input.timeoutMs ?? 1800)), + ]); + if (!raw) return null; + const json = extractJsonObject(raw); + if (!json) return null; + const parsed = JSON.parse(json) as Partial; + const normalized = normalizeSurfaceIntent(parsed, input.deterministicIntent); + return normalized.confidence >= MIN_MODEL_CONFIDENCE ? normalized : null; + } catch { + return null; + } +} + +function buildIntentClassifierPrompt( + capabilities: CapabilityPack | null, + components: ComponentPack | null, +): string { + const capabilityLines = (capabilities?.intents ?? []) + .map((intent) => { + const data = intentData(intent); + const authority = intentAuthority(intent); + return `- ${intent.name}: ${intent.kind ?? 'action'}, data=${data}, authority=${authority}, ${intent.description}`; + }) + .join('\n') || '- none'; + const componentLines = (components?.components ?? []) + .map((component) => { + const surface = component.surface ?? {}; + return `- ${component.name}: data=${surface.data ?? 'embedded'}, authority=${surface.authority ?? 'none'}, ${component.description}`; + }) + .join('\n') || '- none'; + + return `Classify a Summon generative-UI request into a bounded intent object. + +Available host tools: +${capabilityLines} + +Available trusted components: +${componentLines} + +Respond with ONLY one JSON object. No markdown and no prose. +Shape: +{"purpose":"inform|compare|explore|collect|review|operate","interaction":"none|select|form|search|background|approval","dataNeed":"embedded|host-resource|worker","sideEffect":"none|local-state|external-action|approval-required","requestedCapabilities":["name"],"requestedComponents":["name"],"confidence":0.0,"rationale":"short reason"} + +Rules: +- Pick only capability and component names from the lists above. +- Use "none" interaction for read-only summaries, comparisons, explainers, and dashboards. +- Use "search" for host data lookup, browse, filter, or query surfaces. +- Use "form" for submit/intake/collect surfaces. +- Use "select" for choose, save, pick, vote, or local host-action surfaces. +- Use "background" for worker-style analysis, scoring, simulation, or compute. +- Use "approval" and "approval-required" for publish, send, delete, update, deploy, payment, or other external side effects. +- Do not request broader authority than the prompt needs.`; +} + +async function resolveHostPolicy( + input: HostPolicyResolutionRequest & { resolver: HostPolicyResolver | null }, +): Promise { + const narrowed = narrowSurfacePolicy(input.proposedSurfacePolicy, { + capabilities: input.capabilities ?? null, + components: input.components ?? null, + }); + if (!input.resolver) { + return { + source: 'default', + proposedSurfacePolicy: input.proposedSurfacePolicy, + surfacePolicy: narrowed.surfacePolicy, + rejectedCapabilities: narrowed.rejectedCapabilities, + rejectedComponents: narrowed.rejectedComponents, + fallback: narrowed.fallback, + }; + } + + const hostPolicy = await input.resolver({ + prompt: input.prompt, + intent: input.intent, + proposedSurfacePolicy: narrowed.surfacePolicy, + capabilities: input.capabilities ?? null, + components: input.components ?? null, + }); + const hostNarrowed = narrowSurfacePolicy(hostPolicy ?? { tier: 'static', purpose: 'inform' }, { + capabilities: input.capabilities ?? null, + components: input.components ?? null, + }); + return { + source: 'host', + proposedSurfacePolicy: input.proposedSurfacePolicy, + surfacePolicy: hostNarrowed.surfacePolicy, + rejectedCapabilities: [ + ...new Set([...narrowed.rejectedCapabilities, ...hostNarrowed.rejectedCapabilities]), + ], + rejectedComponents: [ + ...new Set([...narrowed.rejectedComponents, ...hostNarrowed.rejectedComponents]), + ], + fallback: narrowed.fallback || hostNarrowed.fallback || hostPolicy === null, + }; +} + +function narrowSurfacePolicy( + policy: SurfacePolicy, + options: { + capabilities: CapabilityPack | null; + components: ComponentPack | null; + }, +): { + surfacePolicy: SurfacePolicy; + rejectedCapabilities: string[]; + rejectedComponents: string[]; + fallback: boolean; +} { + const capabilityNames = new Set((options.capabilities?.intents ?? []).map((intent) => intent.name)); + const componentNames = new Set((options.components?.components ?? []).map((component) => component.name)); + const rawGrants = Array.isArray(policy.grants) ? policy.grants.filter(isString) : []; + const rawComponents = Array.isArray(policy.components) ? policy.components.filter(isString) : []; + const knownGrantNames = rawGrants.filter((name) => capabilityNames.has(name)); + const knownComponentNames = rawComponents.filter((name) => componentNames.has(name)); + const knownIntents = intentsByName(options.capabilities, knownGrantNames); + const knownComponents = componentsByName(options.components, knownComponentNames); + + const rejectedCapabilities = rawGrants.filter((name) => !capabilityNames.has(name)); + const rejectedComponents = rawComponents.filter((name) => !componentNames.has(name)); + const tier = strongestTier(policy.tier, knownIntents, knownComponents); + const grants = knownIntents + .filter((intent) => intentAllowedForTier(tier, intent)) + .map((intent) => intent.name); + const components = knownComponents + .filter((component) => componentAllowedForTier(tier, component)) + .map((component) => component.name); + + for (const name of knownGrantNames) { + if (!grants.includes(name)) rejectedCapabilities.push(name); + } + for (const name of knownComponentNames) { + if (!components.includes(name)) rejectedComponents.push(name); + } + + if ((tier === 'worker' && grants.length === 0 && !knownComponents.some((component) => componentSurfaceData(component) === 'worker')) || + (tier === 'approval' && !knownIntents.some((intent) => intentAuthority(intent) === 'approval-gated'))) { + return { + surfacePolicy: staticFallbackPolicy(policy), + rejectedCapabilities: [...new Set([...rejectedCapabilities, ...knownGrantNames])], + rejectedComponents: [...new Set([...rejectedComponents, ...knownComponentNames])], + fallback: true, + }; + } + + const surfacePolicy: SurfacePolicy = { + tier, + purpose: PURPOSES.has(policy.purpose as SurfacePurpose) + ? policy.purpose + : tier === 'approval' + ? 'operate' + : 'inform', + ...(grants.length > 0 ? { grants } : {}), + ...(components.length > 0 ? { components } : {}), + persistence: policy.persistence === 'ephemeral' ? 'ephemeral' : 'replayable', + }; + const compiled = compileSurfacePolicy(surfacePolicy, { + capabilities: options.capabilities, + components: options.components, + }); + if (compiled.issues.some((issue) => issue.severity === 'block')) { + return { + surfacePolicy: staticFallbackPolicy(policy), + rejectedCapabilities: [...new Set([...rejectedCapabilities, ...knownGrantNames])], + rejectedComponents: [...new Set([...rejectedComponents, ...knownComponentNames])], + fallback: true, + }; + } + return { + surfacePolicy, + rejectedCapabilities: [...new Set(rejectedCapabilities)], + rejectedComponents: [...new Set(rejectedComponents)], + fallback: false, + }; +} + +function strongestTier( + proposedTier: SurfacePolicy['tier'], + intents: IntentSpec[], + components: ComponentSpec[], +): SurfacePolicy['tier'] { + if (proposedTier === 'static') return 'static'; + if (intents.some((intent) => intentAuthority(intent) === 'approval-gated')) return 'approval'; + if (intents.some((intent) => intentData(intent) === 'worker') || + components.some((component) => componentSurfaceData(component) === 'worker')) { + return 'worker'; + } + return proposedTier === 'scripted' ? 'declarative' : proposedTier; +} + +function staticFallbackPolicy(policy: SurfacePolicy): SurfacePolicy { + return { + tier: 'static', + purpose: PURPOSES.has(policy.purpose as SurfacePurpose) ? policy.purpose : 'inform', + persistence: policy.persistence === 'ephemeral' ? 'ephemeral' : 'replayable', + }; +} + +function intentAllowedForTier(tier: SurfacePolicy['tier'], intent: IntentSpec): boolean { + if (tier === 'static') return false; + const data = intentData(intent); + const authority = intentAuthority(intent); + if (tier === 'worker') return data === 'worker'; + if (tier === 'approval') return authority === 'approval-gated'; + return data !== 'worker' && authority !== 'approval-gated'; +} + +function componentAllowedForTier(tier: SurfacePolicy['tier'], component: ComponentSpec): boolean { + const data = componentSurfaceData(component); + const authority = componentSurfaceAuthority(component); + if (tier === 'static') return data === 'embedded' && authority === 'none'; + if (tier === 'worker') return data === 'worker'; + if (tier === 'approval') return authority === 'none' || authority === 'approval-gated'; + return data !== 'worker' && authority !== 'approval-gated'; +} + +function inferCapabilityNames(prompt: string, pack: CapabilityPack | null): string[] { + const intents = pack?.intents ?? []; + if (intents.length === 0) return []; + const text = prompt.toLowerCase(); + const matches = intents.filter((intent) => capabilityMatchesIntent(text, intent)); + if (matches.length > 0) return matches.map((intent) => intent.name); + + const approval = APPROVAL_RE.test(prompt) + ? intents.filter((intent) => intentAuthority(intent) === 'approval-gated') + : []; + if (approval.length === 1) return [approval[0]!.name]; + + const worker = BACKGROUND_RE.test(prompt) + ? intents.filter((intent) => intentData(intent) === 'worker') + : []; + if (worker.length > 0) return worker.map((intent) => intent.name); + + const resource = SEARCH_RE.test(prompt) + ? intents.filter((intent) => intentData(intent) === 'host-resource') + : []; + if (resource.length === 1) return [resource[0]!.name]; + + const actions = intents.filter((intent) => intentAuthority(intent) === 'host-action'); + if ((FORM_RE.test(prompt) || SELECT_RE.test(prompt)) && actions.length === 1) { + return [actions[0]!.name]; + } + return []; +} + +function capabilityMatchesIntent(text: string, intent: IntentSpec): boolean { + const haystack = `${intent.name} ${intent.description}`.toLowerCase(); + const terms = new Set([ + ...intent.name.toLowerCase().split(/[_\W]+/), + ...intent.description.toLowerCase().split(/[_\W]+/).filter((term) => term.length > 4), + ]); + for (const term of terms) { + if (term && text.includes(term)) return true; + } + if (SEARCH_RE.test(text) && intentData(intent) === 'host-resource') return true; + if (BACKGROUND_RE.test(text) && intentData(intent) === 'worker') return true; + if (APPROVAL_RE.test(text) && intentAuthority(intent) === 'approval-gated') return true; + return false; +} + +function inferComponentNames(prompt: string, pack: ComponentPack | null): string[] { + const components = pack?.components ?? []; + if (components.length === 0) return []; + const text = prompt.toLowerCase(); + return components + .filter((component) => { + const name = component.name.toLowerCase(); + const spacedName = name.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase(); + return text.includes(name) || + text.includes(spacedName) || + text.includes(component.description.toLowerCase()); + }) + .map((component) => component.name); +} + +function normalizeSurfaceIntent(raw: Partial, fallback: SurfaceIntent): SurfaceIntent { + return { + purpose: PURPOSES.has(raw.purpose as SurfacePurpose) ? raw.purpose as SurfacePurpose : fallback.purpose, + interaction: enumValue(raw.interaction, ['none', 'select', 'form', 'search', 'background', 'approval']) ?? + fallback.interaction, + dataNeed: enumValue(raw.dataNeed, ['embedded', 'host-resource', 'worker']) ?? fallback.dataNeed, + sideEffect: enumValue(raw.sideEffect, ['none', 'local-state', 'external-action', 'approval-required']) ?? + fallback.sideEffect, + requestedCapabilities: stringList(raw.requestedCapabilities), + requestedComponents: stringList(raw.requestedComponents), + confidence: typeof raw.confidence === 'number' && Number.isFinite(raw.confidence) + ? Math.max(0, Math.min(1, raw.confidence)) + : fallback.confidence, + rationale: typeof raw.rationale === 'string' ? raw.rationale.slice(0, 240) : fallback.rationale, + }; +} + +function sanitizeSurfaceIntent( + intent: SurfaceIntent, + options: { + capabilities: CapabilityPack | null; + components: ComponentPack | null; + fallback: SurfaceIntent; + }, +): SurfaceIntent { + const capabilityNames = new Set((options.capabilities?.intents ?? []).map((item) => item.name)); + const componentNames = new Set((options.components?.components ?? []).map((item) => item.name)); + const requestedCapabilities = intent.requestedCapabilities.filter((name) => capabilityNames.has(name)); + const requestedComponents = intent.requestedComponents.filter((name) => componentNames.has(name)); + if ( + intent.interaction !== 'none' && + requestedCapabilities.length === 0 && + requestedComponents.length === 0 && + options.fallback.requestedCapabilities.length > 0 + ) { + requestedCapabilities.push(...options.fallback.requestedCapabilities); + } + return { + ...intent, + requestedCapabilities: [...new Set(requestedCapabilities)], + requestedComponents: [...new Set(requestedComponents)], + }; +} + +function inferPurpose(prompt: string): SurfacePurpose { + const text = prompt.toLowerCase(); + if (/\b(compare|comparison|versus|vs\.?|pros|cons|trade-?offs?)\b/.test(text)) return 'compare'; + if (/\b(collect|intake|form|survey|questionnaire|submit)\b/.test(text)) return 'collect'; + if (/\b(search|find|explore|browse|filter|lookup|discover)\b/.test(text)) return 'explore'; + if (/\b(approve|approval|review|audit|confirm|verify|readiness|risk)\b/.test(text)) return 'review'; + if (/\b(update|create|delete|save|send|publish|change|operate|run|deploy)\b/.test(text)) return 'operate'; + return 'inform'; +} + +function intentsByName(pack: CapabilityPack | null | undefined, names: string[]): IntentSpec[] { + const byName = new Map((pack?.intents ?? []).map((intent) => [intent.name, intent])); + return names.map((name) => byName.get(name)).filter((intent): intent is IntentSpec => Boolean(intent)); +} + +function componentsByName(pack: ComponentPack | null | undefined, names: string[]): ComponentSpec[] { + const byName = new Map((pack?.components ?? []).map((component) => [component.name, component])); + return names.map((name) => byName.get(name)).filter((component): component is ComponentSpec => Boolean(component)); +} + +function intentData(intent: IntentSpec): SurfaceIntentDataNeed { + return intent.surface?.data ?? (intent.kind === 'resource' ? 'host-resource' : 'embedded'); +} + +function intentAuthority(intent: IntentSpec): 'none' | 'read' | 'host-action' | 'approval-gated' { + return intent.surface?.authority ?? (intent.kind === 'resource' ? 'read' : 'host-action'); +} + +function componentSurfaceData(component: ComponentSpec): SurfaceIntentDataNeed { + return component.surface?.data ?? 'embedded'; +} + +function componentSurfaceAuthority(component: ComponentSpec): 'none' | 'read' | 'host-action' | 'approval-gated' { + return component.surface?.authority ?? 'none'; +} + +function enumValue(raw: unknown, values: readonly T[]): T | null { + return typeof raw === 'string' && (values as readonly string[]).includes(raw) ? raw as T : null; +} + +function stringList(raw: unknown): string[] { + if (!Array.isArray(raw)) return []; + return [...new Set(raw.filter(isString))]; +} + +function isString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function extractJsonObject(raw: string): string | null { + const cleaned = raw + .replace(/^[\s\S]*?```(?:json)?\s*/i, '') + .replace(/\s*```[\s\S]*$/, '') + .trim(); + const candidate = cleaned.startsWith('{') ? cleaned : raw; + const match = candidate.match(/\{[\s\S]*\}/); + return match ? match[0] : null; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 519a6d5..0e05a1d 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,9 +1,35 @@ export { generateSurfaceStream } from './compat.js'; +export { + defaultHostPolicyResolver, + inferSurfaceIntent, + planAgentSurface, + policyFromIntent, + runAgentSurfaceGeneration, +} from './agent-broker.js'; export { buildEditBlock } from './edit.js'; export { resolveSurfaceGenerationPlan } from './plan.js'; export { runSurfaceGeneration } from './runner.js'; export { summarizeContractIssues } from './summary.js'; +export type { + AgentIntentProvider, + AgentIntentRequest, + AgentIntentTextClient, + AgentIntentTextRequest, + AgentPolicyResolution, + AgentSurfaceGenerationInput, + AgentSurfaceGenerationSummary, + AgentSurfacePlanResult, + AgentSurfacePlanningInput, + AgentSurfacePlanningOptions, + HostPolicyResolutionRequest, + HostPolicyResolver, + SurfaceIntent, + SurfaceIntentDataNeed, + SurfaceIntentInteraction, + SurfaceIntentSideEffect, +} from './agent-broker.js'; + export type { GenerateEditInput, GenerateSurfaceInput, diff --git a/packages/server/test/agent-broker.test.ts b/packages/server/test/agent-broker.test.ts new file mode 100644 index 0000000..f4c4c50 --- /dev/null +++ b/packages/server/test/agent-broker.test.ts @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + inferSurfaceIntent, + planAgentSurface, + runAgentSurfaceGeneration, + type AgentIntentTextClient, + type SummonModelProvider, +} from '../src/index.ts'; +import type { + CapabilityPack, + ProtocolLine, +} from '@summon-internal/engine'; + +const capabilities: CapabilityPack = { + intents: [ + { + name: 'search', + description: 'Search host-owned recipe data.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'host-resource', authority: 'read' }, + }, + { + name: 'choose', + description: 'Save a user choice.', + argsSchema: '{}', + stateShape: '{}', + kind: 'action', + surface: { authority: 'host-action' }, + }, + { + name: 'analysis', + description: 'Run background risk analysis.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'worker', authority: 'read' }, + }, + { + name: 'publish_summary', + description: 'Publish a prepared summary after host approval.', + argsSchema: '{}', + stateShape: '{}', + kind: 'action', + surface: { authority: 'approval-gated' }, + }, + ], +}; + +test('inferSurfaceIntent maps search prompts to host-resource intent', () => { + const intent = inferSurfaceIntent( + 'build a dinner finder where i can search recipes and browse results', + { capabilities }, + ); + + assert.equal(intent.purpose, 'explore'); + assert.equal(intent.interaction, 'search'); + assert.equal(intent.dataNeed, 'host-resource'); + assert.deepEqual(intent.requestedCapabilities, ['search']); +}); + +test('planAgentSurface proposes and compiles a declarative policy', async () => { + const plan = await planAgentSurface({ + prompt: 'build a dinner finder where i can search recipes', + capabilities, + }); + + assert.deepEqual(plan.surfacePolicy, { + tier: 'declarative', + purpose: 'explore', + grants: ['search'], + persistence: 'replayable', + }); + assert.deepEqual(plan.compiledPolicy.issues, []); + assert.deepEqual(plan.compiledPolicy.surfacePlan, { + purpose: 'explore', + runtime: 'declarative', + data: 'host-resource', + authority: 'read', + persistence: 'replayable', + }); +}); + +test('planAgentSurface selects worker and approval tiers from catalog-backed intent', async () => { + const worker = await planAgentSurface({ + prompt: 'analyze launch risk in the background and score readiness', + capabilities, + }); + assert.equal(worker.surfacePolicy.tier, 'worker'); + assert.deepEqual(worker.surfacePolicy.grants, ['analysis']); + + const approval = await planAgentSurface({ + prompt: 'publish the prepared product update summary', + capabilities, + }); + assert.equal(approval.surfacePolicy.tier, 'approval'); + assert.deepEqual(approval.surfacePolicy.grants, ['publish_summary']); + assert.equal(approval.surfacePolicy.purpose, 'operate'); +}); + +test('model-assisted intent can narrow to known names but cannot add unknown grants', async () => { + const intentModel: AgentIntentTextClient = { + completeText: async () => JSON.stringify({ + purpose: 'operate', + interaction: 'approval', + dataNeed: 'embedded', + sideEffect: 'approval-required', + requestedCapabilities: ['missing', 'publish_summary'], + requestedComponents: ['MysteryCard'], + confidence: 0.91, + }), + }; + + const plan = await planAgentSurface({ + prompt: 'publish the prepared product update summary', + capabilities, + intentModel, + }); + + assert.equal(plan.surfacePolicy.tier, 'approval'); + assert.deepEqual(plan.surfacePolicy.grants, ['publish_summary']); + assert.equal(plan.surfacePolicy.components, undefined); + assert.deepEqual(plan.policyResolution.rejectedCapabilities, []); +}); + +test('host policy resolver can force a static fallback', async () => { + const plan = await planAgentSurface({ + prompt: 'build a dinner finder where i can search recipes', + capabilities, + hostPolicyResolver: () => null, + }); + + assert.equal(plan.policyResolution.source, 'host'); + assert.equal(plan.policyResolution.fallback, true); + assert.deepEqual(plan.surfacePolicy, { + tier: 'static', + purpose: 'inform', + persistence: 'replayable', + }); +}); + +test('runAgentSurfaceGeneration emits agent diagnostics before policy metadata', async () => { + const lines: ProtocolLine[] = []; + const provider: SummonModelProvider = async function* () { + yield '{"op":"set","path":"/screen","value":{"sections":["hero"]}}\n'; + yield '{"op":"add","path":"/section/hero","html":"

Dinner finder

Ready.

"}\n'; + }; + + const summary = await runAgentSurfaceGeneration({ + prompt: 'build a dinner finder where i can search recipes', + capabilities, + modelProvider: provider, + }, (line) => { + lines.push(line); + }); + + assert.deepEqual(lines.slice(0, 5).map((line) => `${line.op} ${line.path}`), [ + 'meta /agent-intent', + 'meta /agent-policy-resolution', + 'meta /surface-policy', + 'meta /surface-plan', + 'meta /surface-contract', + ]); + assert.equal(summary.agent.surfacePolicy.tier, 'declarative'); + assert.equal(summary.blocked, false); +}); diff --git a/packages/summon-server/src/index.ts b/packages/summon-server/src/index.ts index 7a7c520..013f4ef 100644 --- a/packages/summon-server/src/index.ts +++ b/packages/summon-server/src/index.ts @@ -1,16 +1,33 @@ export { + defaultHostPolicyResolver, generateSurfaceStream, + inferSurfaceIntent, + planAgentSurface, + policyFromIntent, resolveSurfaceGenerationPlan, + runAgentSurfaceGeneration, runSurfaceGeneration, summarizeContractIssues, } from '@summon-internal/server'; export type { + AgentIntentProvider, + AgentIntentRequest, + AgentIntentTextClient, + AgentIntentTextRequest, + AgentPolicyResolution, + AgentSurfaceGenerationInput, + AgentSurfaceGenerationSummary, + AgentSurfacePlanResult, + AgentSurfacePlanningInput, + AgentSurfacePlanningOptions, ContractIssue, ContractPromptBlock, GenerateEditInput, GenerateSurfaceInput, GenerationSummary, GhostGenerationContext, + HostPolicyResolutionRequest, + HostPolicyResolver, ProtocolLine, ProtocolSkipMetaValue, RepairFeedbackMetaValue, @@ -23,6 +40,10 @@ export type { SummonModelRequest, SummonRepairProvider, SummonRepairRequest, + SurfaceIntent, + SurfaceIntentDataNeed, + SurfaceIntentInteraction, + SurfaceIntentSideEffect, SurfaceGenerationInput, SurfaceGenerationSummary, } from '@summon-internal/server'; diff --git a/scripts/build-public-packages.mjs b/scripts/build-public-packages.mjs index 5825a8e..4b6da83 100644 --- a/scripts/build-public-packages.mjs +++ b/scripts/build-public-packages.mjs @@ -473,20 +473,37 @@ const serverExports = { '.': { values: { './_internal/server/index.js': [ + 'defaultHostPolicyResolver', 'generateSurfaceStream', + 'inferSurfaceIntent', + 'planAgentSurface', + 'policyFromIntent', 'resolveSurfaceGenerationPlan', + 'runAgentSurfaceGeneration', 'runSurfaceGeneration', 'summarizeContractIssues', ], }, types: { './_internal/server/index.js': [ + 'AgentIntentProvider', + 'AgentIntentRequest', + 'AgentIntentTextClient', + 'AgentIntentTextRequest', + 'AgentPolicyResolution', + 'AgentSurfaceGenerationInput', + 'AgentSurfaceGenerationSummary', + 'AgentSurfacePlanResult', + 'AgentSurfacePlanningInput', + 'AgentSurfacePlanningOptions', 'ContractIssue', 'ContractPromptBlock', 'GenerateEditInput', 'GenerateSurfaceInput', 'GenerationSummary', 'GhostGenerationContext', + 'HostPolicyResolutionRequest', + 'HostPolicyResolver', 'ProtocolLine', 'ProtocolSkipMetaValue', 'RepairFeedbackMetaValue', @@ -499,6 +516,10 @@ const serverExports = { 'SummonModelRequest', 'SummonRepairProvider', 'SummonRepairRequest', + 'SurfaceIntent', + 'SurfaceIntentDataNeed', + 'SurfaceIntentInteraction', + 'SurfaceIntentSideEffect', 'SurfaceGenerationInput', 'SurfaceGenerationSummary', ], From b0dd3a01baa256b9ccb33706ec9e3a60f0fc56c6 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 11 Jun 2026 12:23:18 -0400 Subject: [PATCH 2/4] Harden agent broker planning --- apps/server/src/generate-route.test.ts | 4 +- apps/server/src/main.ts | 13 +++---- docs/adoption/integration.md | 6 ++- docs/adoption/package-consumption.md | 7 ++-- packages/server/src/agent-broker.ts | 47 ++++++++++++++++------- packages/server/test/agent-broker.test.ts | 34 ++++++++++++++++ 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/apps/server/src/generate-route.test.ts b/apps/server/src/generate-route.test.ts index e0c5fa5..480c237 100644 --- a/apps/server/src/generate-route.test.ts +++ b/apps/server/src/generate-route.test.ts @@ -265,14 +265,14 @@ test('api generate sends narrowed contract and stream meta shape through package .filter(Boolean) .map((raw) => JSON.parse(raw) as ProtocolLine); assert.deepEqual(agentLines.slice(0, 6).map((line) => `${line.op} ${line.path}`), [ + 'meta /mode-upgraded', 'meta /agent-intent', 'meta /agent-policy-resolution', - 'meta /mode-upgraded', 'meta /surface-policy', 'meta /surface-plan', 'meta /surface-contract', ]); - const agentIntent = agentLines[0] as Extract; + const agentIntent = agentLines[1] as Extract; assert.equal((agentIntent.value as { interaction?: unknown }).interaction, 'search'); const agentPolicy = agentLines[3] as Extract; assert.deepEqual(agentPolicy.value, { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 7051fec..7bb0638 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -621,6 +621,12 @@ app.post('/api/generate', async (req, res) => { }); } } + // Emit the mode-upgrade signal before agent diagnostics. The client respawns + // its sandbox into interactive mode in response, so this should land before + // any broker or artifact bytes that assume the upgraded mode. + if (modeUpgraded) { + preludeLines.push({ op: 'meta', path: '/mode-upgraded', value: 'static→interactive' }); + } if (agentPlan) { preludeLines.push({ op: 'meta', @@ -640,13 +646,6 @@ app.post('/api/generate', async (req, res) => { }, }); } - - // Emit the mode-upgrade signal as the first byte the client sees, before - // any concurrency wait. The client respawns its sandbox into interactive - // mode in response, so we want this to land before any artifact bytes. - if (modeUpgraded) { - preludeLines.push({ op: 'meta', path: '/mode-upgraded', value: 'static→interactive' }); - } if (shape) { preludeLines.push({ op: 'meta', path: '/shape', value: shape }); } diff --git a/docs/adoption/integration.md b/docs/adoption/integration.md index f5e5170..6b381d9 100644 --- a/docs/adoption/integration.md +++ b/docs/adoption/integration.md @@ -210,8 +210,10 @@ await runAgentSurfaceGeneration({ The broker emits `/agent-intent` and `/agent-policy-resolution` diagnostics, then generation continues through the normal `/surface-policy`, -`/surface-plan`, and `/surface-contract` path. The inferred intent is advisory: -the host resolver and Summon policy compiler still own authority. +`/surface-plan`, and `/surface-contract` path. `SurfaceIntent` is an +experimental planning shape, not an authority contract. The inferred intent is +advisory: the host resolver and `compileSurfacePolicy()` still decide which +tools, components, runtime, and approval paths are actually available. ### Surface Contract View diff --git a/docs/adoption/package-consumption.md b/docs/adoption/package-consumption.md index 6be0057..2181885 100644 --- a/docs/adoption/package-consumption.md +++ b/docs/adoption/package-consumption.md @@ -232,9 +232,10 @@ await runAgentSurfaceGeneration({ ``` The intent converter can use rules, a model-assisted `intentModel`, or a custom -`intentProvider`. Its output is never authority. The host resolver and -`compileSurfacePolicy()` still decide which host tools, components, runtime, -and approval paths are actually available. +`intentProvider`. `SurfaceIntent` is experimental and advisory; its output is +never authority. The host resolver and `compileSurfacePolicy()` still decide +which host tools, components, runtime, and approval paths are actually +available. ## Package Gate diff --git a/packages/server/src/agent-broker.ts b/packages/server/src/agent-broker.ts index 08e27ac..85b12e0 100644 --- a/packages/server/src/agent-broker.ts +++ b/packages/server/src/agent-broker.ts @@ -127,7 +127,7 @@ const PURPOSES = new Set(SURFACE_PURPOSE_VALUES); const MIN_MODEL_CONFIDENCE = 0.45; const APPROVAL_RE = - /\b(approve|approval|confirm|publish|send|email|message|post|delete|remove|archive|update|change|commit|merge|deploy|invite|charge|pay|purchase|refund)\b/i; + /\b(approve|approval|confirm|publish|send|email|post|delete|remove|archive|commit|merge|deploy|invite|charge|pay|purchase|refund)\b|\b(update|change)\s+(the|this|that|a|an|my|our)\b/i; const BACKGROUND_RE = /\b(analy[sz]e|analysis|calculate|compute|forecast|simulate|score|rank|audit|batch|background|long[-\s]?running)\b/i; const SEARCH_RE = @@ -136,6 +136,11 @@ const FORM_RE = /\b(form|collect|intake|survey|questionnaire|submit|capture|enter|input)\b/i; const SELECT_RE = /\b(pick|choose|select|save|remember|vote|rate|rank|favorite)\b/i; +const DIRECT_CAPABILITY_NAME_RE = /[_\W]+/; +const FORM_CAPABILITY_RE = + /\b(submit|form|collect|intake|survey|questionnaire|capture|input)\b/i; +const SELECT_CAPABILITY_RE = + /\b(choose|choice|select|save|pick|vote|rate|rank|favorite|remember)\b/i; export async function planAgentSurface( input: AgentSurfacePlanningInput, @@ -582,7 +587,7 @@ function inferCapabilityNames(prompt: string, pack: CapabilityPack | null): stri const intents = pack?.intents ?? []; if (intents.length === 0) return []; const text = prompt.toLowerCase(); - const matches = intents.filter((intent) => capabilityMatchesIntent(text, intent)); + const matches = intents.filter((intent) => capabilityMatchesIntent(prompt, text, intent)); if (matches.length > 0) return matches.map((intent) => intent.name); const approval = APPROVAL_RE.test(prompt) @@ -607,21 +612,37 @@ function inferCapabilityNames(prompt: string, pack: CapabilityPack | null): stri return []; } -function capabilityMatchesIntent(text: string, intent: IntentSpec): boolean { - const haystack = `${intent.name} ${intent.description}`.toLowerCase(); - const terms = new Set([ - ...intent.name.toLowerCase().split(/[_\W]+/), - ...intent.description.toLowerCase().split(/[_\W]+/).filter((term) => term.length > 4), - ]); - for (const term of terms) { - if (term && text.includes(term)) return true; +function capabilityMatchesIntent(prompt: string, text: string, intent: IntentSpec): boolean { + if (matchesCapabilityName(text, intent.name)) return true; + + const data = intentData(intent); + const authority = intentAuthority(intent); + if (APPROVAL_RE.test(prompt)) return authority === 'approval-gated'; + if (BACKGROUND_RE.test(prompt)) return data === 'worker'; + if (SEARCH_RE.test(prompt)) return data === 'host-resource'; + if (FORM_RE.test(prompt)) { + return authority === 'host-action' && data !== 'worker' && capabilityClassMatches(intent, FORM_CAPABILITY_RE); + } + if (SELECT_RE.test(prompt)) { + return authority === 'host-action' && data !== 'worker' && capabilityClassMatches(intent, SELECT_CAPABILITY_RE); } - if (SEARCH_RE.test(text) && intentData(intent) === 'host-resource') return true; - if (BACKGROUND_RE.test(text) && intentData(intent) === 'worker') return true; - if (APPROVAL_RE.test(text) && intentAuthority(intent) === 'approval-gated') return true; return false; } +function matchesCapabilityName(text: string, name: string): boolean { + const normalizedName = name.toLowerCase(); + if (text.includes(normalizedName)) return true; + const terms = normalizedName + .split(DIRECT_CAPABILITY_NAME_RE) + .filter((term) => term.length > 2); + if (terms.length === 0) return false; + return terms.every((term) => text.includes(term)); +} + +function capabilityClassMatches(intent: IntentSpec, pattern: RegExp): boolean { + return pattern.test(`${intent.name} ${intent.description}`); +} + function inferComponentNames(prompt: string, pack: ComponentPack | null): string[] { const components = pack?.components ?? []; if (components.length === 0) return []; diff --git a/packages/server/test/agent-broker.test.ts b/packages/server/test/agent-broker.test.ts index f4c4c50..a2f18ca 100644 --- a/packages/server/test/agent-broker.test.ts +++ b/packages/server/test/agent-broker.test.ts @@ -83,6 +83,22 @@ test('planAgentSurface proposes and compiles a declarative policy', async () => }); }); +test('planAgentSurface keeps passive summary prompts static despite powerful nouns', async () => { + const updateSummary = await planAgentSurface({ + prompt: 'make a product update summary for this launch', + capabilities, + }); + assert.equal(updateSummary.surfacePolicy.tier, 'static'); + assert.equal(updateSummary.surfacePolicy.grants, undefined); + + const riskSummary = await planAgentSurface({ + prompt: 'summarize launch risk for next week', + capabilities, + }); + assert.equal(riskSummary.surfacePolicy.tier, 'static'); + assert.equal(riskSummary.surfacePolicy.grants, undefined); +}); + test('planAgentSurface selects worker and approval tiers from catalog-backed intent', async () => { const worker = await planAgentSurface({ prompt: 'analyze launch risk in the background and score readiness', @@ -100,6 +116,24 @@ test('planAgentSurface selects worker and approval tiers from catalog-backed int assert.equal(approval.surfacePolicy.purpose, 'operate'); }); +test('planAgentSurface selects host actions only from explicit action phrasing', async () => { + const plan = await planAgentSurface({ + prompt: 'let me save a choice for the launch announcement', + capabilities, + }); + + assert.equal(plan.surfacePolicy.tier, 'declarative'); + assert.equal(plan.surfacePolicy.purpose, 'operate'); + assert.deepEqual(plan.surfacePolicy.grants, ['choose']); + assert.deepEqual(plan.compiledPolicy.surfacePlan, { + purpose: 'operate', + runtime: 'declarative', + data: 'embedded', + authority: 'host-action', + persistence: 'replayable', + }); +}); + test('model-assisted intent can narrow to known names but cannot add unknown grants', async () => { const intentModel: AgentIntentTextClient = { completeText: async () => JSON.stringify({ From e780110490b64526ef285777aceb71a1f59d73a1 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 11 Jun 2026 12:47:53 -0400 Subject: [PATCH 3/4] Narrow agent broker capability inference --- packages/server/src/agent-broker.ts | 44 +++++++------ packages/server/test/agent-broker.test.ts | 80 +++++++++++++++++++++++ 2 files changed, 104 insertions(+), 20 deletions(-) diff --git a/packages/server/src/agent-broker.ts b/packages/server/src/agent-broker.ts index 85b12e0..90816a6 100644 --- a/packages/server/src/agent-broker.ts +++ b/packages/server/src/agent-broker.ts @@ -254,6 +254,7 @@ export function policyFromIntent( options: { persistence?: SurfacePersistence } = {}, ): SurfacePolicy { const persistence = options.persistence ?? 'replayable'; + const hasSurfaceAccess = intent.requestedCapabilities.length > 0 || intent.requestedComponents.length > 0; if (intent.sideEffect === 'approval-required' || intent.interaction === 'approval') { return { tier: 'approval', @@ -273,10 +274,13 @@ export function policyFromIntent( }; } if ( - intent.interaction !== 'none' || - intent.dataNeed === 'host-resource' || - intent.sideEffect === 'local-state' || - intent.sideEffect === 'external-action' + hasSurfaceAccess && + ( + intent.interaction !== 'none' || + intent.dataNeed === 'host-resource' || + intent.sideEffect === 'local-state' || + intent.sideEffect === 'external-action' + ) ) { return { tier: 'declarative', @@ -587,44 +591,40 @@ function inferCapabilityNames(prompt: string, pack: CapabilityPack | null): stri const intents = pack?.intents ?? []; if (intents.length === 0) return []; const text = prompt.toLowerCase(); - const matches = intents.filter((intent) => capabilityMatchesIntent(prompt, text, intent)); - if (matches.length > 0) return matches.map((intent) => intent.name); + const directMatches = intents.filter((intent) => matchesCapabilityName(text, intent.name)); + if (directMatches.length > 0) return directMatches.map((intent) => intent.name); const approval = APPROVAL_RE.test(prompt) ? intents.filter((intent) => intentAuthority(intent) === 'approval-gated') : []; - if (approval.length === 1) return [approval[0]!.name]; + if (approval.length > 0) return singleCandidateNames(approval); const worker = BACKGROUND_RE.test(prompt) ? intents.filter((intent) => intentData(intent) === 'worker') : []; - if (worker.length > 0) return worker.map((intent) => intent.name); + if (worker.length > 0) return singleCandidateNames(worker); const resource = SEARCH_RE.test(prompt) ? intents.filter((intent) => intentData(intent) === 'host-resource') : []; - if (resource.length === 1) return [resource[0]!.name]; + if (resource.length > 0) return singleCandidateNames(resource); - const actions = intents.filter((intent) => intentAuthority(intent) === 'host-action'); - if ((FORM_RE.test(prompt) || SELECT_RE.test(prompt)) && actions.length === 1) { - return [actions[0]!.name]; + const actions = intents.filter((intent) => capabilityMatchesActionClass(prompt, intent)); + if (actions.length > 0) { + return singleCandidateNames(actions); } return []; } -function capabilityMatchesIntent(prompt: string, text: string, intent: IntentSpec): boolean { - if (matchesCapabilityName(text, intent.name)) return true; - +function capabilityMatchesActionClass(prompt: string, intent: IntentSpec): boolean { const data = intentData(intent); const authority = intentAuthority(intent); - if (APPROVAL_RE.test(prompt)) return authority === 'approval-gated'; - if (BACKGROUND_RE.test(prompt)) return data === 'worker'; - if (SEARCH_RE.test(prompt)) return data === 'host-resource'; + if (authority !== 'host-action' || data === 'worker') return false; if (FORM_RE.test(prompt)) { - return authority === 'host-action' && data !== 'worker' && capabilityClassMatches(intent, FORM_CAPABILITY_RE); + return capabilityClassMatches(intent, FORM_CAPABILITY_RE); } if (SELECT_RE.test(prompt)) { - return authority === 'host-action' && data !== 'worker' && capabilityClassMatches(intent, SELECT_CAPABILITY_RE); + return capabilityClassMatches(intent, SELECT_CAPABILITY_RE); } return false; } @@ -639,6 +639,10 @@ function matchesCapabilityName(text: string, name: string): boolean { return terms.every((term) => text.includes(term)); } +function singleCandidateNames(intents: IntentSpec[]): string[] { + return intents.length === 1 ? [intents[0]!.name] : []; +} + function capabilityClassMatches(intent: IntentSpec, pattern: RegExp): boolean { return pattern.test(`${intent.name} ${intent.description}`); } diff --git a/packages/server/test/agent-broker.test.ts b/packages/server/test/agent-broker.test.ts index a2f18ca..e56d262 100644 --- a/packages/server/test/agent-broker.test.ts +++ b/packages/server/test/agent-broker.test.ts @@ -49,6 +49,44 @@ const capabilities: CapabilityPack = { ], }; +const multiToolCapabilities: CapabilityPack = { + intents: [ + ...capabilities.intents, + { + name: 'delete_record', + description: 'Delete a selected record after host approval.', + argsSchema: '{}', + stateShape: '{}', + kind: 'action', + surface: { authority: 'approval-gated' }, + }, + { + name: 'github_lookup', + description: 'Look up GitHub profile data.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'host-resource', authority: 'read' }, + }, + { + name: 'compute_score', + description: 'Run background score computation.', + argsSchema: '{}', + stateShape: '{}', + kind: 'action', + surface: { data: 'worker', authority: 'host-action' }, + }, + { + name: 'counter', + description: 'Increment a local counter.', + argsSchema: '{}', + stateShape: '{}', + kind: 'action', + surface: { authority: 'host-action' }, + }, + ], +}; + test('inferSurfaceIntent maps search prompts to host-resource intent', () => { const intent = inferSurfaceIntent( 'build a dinner finder where i can search recipes and browse results', @@ -116,6 +154,48 @@ test('planAgentSurface selects worker and approval tiers from catalog-backed int assert.equal(approval.surfacePolicy.purpose, 'operate'); }); +test('planAgentSurface keeps multi-tool class inference narrow', async () => { + const approval = await planAgentSurface({ + prompt: 'publish the prepared summary', + capabilities: multiToolCapabilities, + }); + assert.equal(approval.surfacePolicy.tier, 'approval'); + assert.deepEqual(approval.surfacePolicy.grants, ['publish_summary']); + + const search = await planAgentSurface({ + prompt: 'search recipes for dinner', + capabilities: multiToolCapabilities, + }); + assert.equal(search.surfacePolicy.tier, 'declarative'); + assert.deepEqual(search.surfacePolicy.grants, ['search']); + + const ambiguousSearch = await planAgentSurface({ + prompt: 'search the host data', + capabilities: { + intents: [ + { + name: 'recipe_lookup', + description: 'Look up recipe data.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'host-resource', authority: 'read' }, + }, + { + name: 'github_lookup', + description: 'Look up GitHub profile data.', + argsSchema: '{}', + stateShape: '{}', + kind: 'resource', + surface: { data: 'host-resource', authority: 'read' }, + }, + ], + }, + }); + assert.equal(ambiguousSearch.surfacePolicy.tier, 'static'); + assert.equal(ambiguousSearch.surfacePolicy.grants, undefined); +}); + test('planAgentSurface selects host actions only from explicit action phrasing', async () => { const plan = await planAgentSurface({ prompt: 'let me save a choice for the launch announcement', From 2c9e4ad3d65d88453cd1566f298d938d44050e64 Mon Sep 17 00:00:00 2001 From: Nahiyan Khan Date: Thu, 11 Jun 2026 13:18:01 -0400 Subject: [PATCH 4/4] Update public API snapshots for agent broker --- scripts/check-public-api.mjs | 5 +++++ scripts/public-api-manifest.json | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/scripts/check-public-api.mjs b/scripts/check-public-api.mjs index ba9fe45..ab733be 100644 --- a/scripts/check-public-api.mjs +++ b/scripts/check-public-api.mjs @@ -27,8 +27,13 @@ const expectedRootExports = [ ].sort(); const expectedServerExports = [ + 'defaultHostPolicyResolver', 'generateSurfaceStream', + 'inferSurfaceIntent', + 'planAgentSurface', + 'policyFromIntent', 'resolveSurfaceGenerationPlan', + 'runAgentSurfaceGeneration', 'runSurfaceGeneration', 'summarizeContractIssues', ].sort(); diff --git a/scripts/public-api-manifest.json b/scripts/public-api-manifest.json index 8b264fa..968e989 100644 --- a/scripts/public-api-manifest.json +++ b/scripts/public-api-manifest.json @@ -430,18 +430,35 @@ ".": { "file": "index", "values": [ + "defaultHostPolicyResolver", "generateSurfaceStream", + "inferSurfaceIntent", + "planAgentSurface", + "policyFromIntent", "resolveSurfaceGenerationPlan", + "runAgentSurfaceGeneration", "runSurfaceGeneration", "summarizeContractIssues" ], "types": [ + "AgentIntentProvider", + "AgentIntentRequest", + "AgentIntentTextClient", + "AgentIntentTextRequest", + "AgentPolicyResolution", + "AgentSurfaceGenerationInput", + "AgentSurfaceGenerationSummary", + "AgentSurfacePlanResult", + "AgentSurfacePlanningInput", + "AgentSurfacePlanningOptions", "ContractIssue", "ContractPromptBlock", "GenerateEditInput", "GenerateSurfaceInput", "GenerationSummary", "GhostGenerationContext", + "HostPolicyResolutionRequest", + "HostPolicyResolver", "ProtocolLine", "ProtocolSkipMetaValue", "RepairFeedbackMetaValue", @@ -454,6 +471,10 @@ "SummonModelRequest", "SummonRepairProvider", "SummonRepairRequest", + "SurfaceIntent", + "SurfaceIntentDataNeed", + "SurfaceIntentInteraction", + "SurfaceIntentSideEffect", "SurfaceGenerationInput", "SurfaceGenerationSummary" ]