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
23 changes: 21 additions & 2 deletions lambda/agentcore/commands/run-stage.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
UNIT_FOR_EACH,
} from '../../shared/v2-execution-plan.js';
import { humanTaskMatchesOwner, isHumanTaskAnswerStatus } from '../../shared/v2-process-keys.js';
import { policyValueUninterpreted } from '../../shared/aidlc-capabilities.js';
import { credentialProviderForCli } from '../../shared/agent-auth-contracts.js';
import {
mergeChangedFileProvenance,
Expand Down Expand Up @@ -2081,8 +2082,9 @@
}

// Release-authored scope policy can silently REMOVE verification (a reviewer, a
// sensor list). Record what it took away so an operator reading the timeline
// can see why a stage ran without the checks its stage block declares.
// sensor list, a stage's support personas). Record what it took away so an
// operator reading the timeline can see why a stage ran without the checks its
// stage block declares.
const policyEffect = (plan.policyEffects ?? []).find((effect) => effect.stageId === stageId);
if (policyEffect) {
await store
Expand All @@ -2099,6 +2101,10 @@
policyEffect.removedSensors.length
? `, sensors removed: ${policyEffect.removedSensors.join(', ')}`
: ''
}${
(policyEffect.withdrawnSupportAgents ?? []).length
? `, support personas withdrawn: ${policyEffect.withdrawnSupportAgents.join(', ')}`
: ''
}`,
})
// Non-fatal, but NOT silent: this event is the only record that the scope
Expand Down Expand Up @@ -2623,6 +2629,19 @@
let changedInputs = [];
let changeControlFindings = [];
let changeControlMessage = '';
// Three outcomes and no fourth means a FOURTH VALUE has nowhere to go: it
// would fall through every branch below and run the stage with no comparison
// at all — the one outcome this section exists to prevent. The plan refuses
// such a value from a user overlay, so arriving here means a release the
// promotion guard holds; fail the stage rather than run it as the absence of
// change control.
if (policyValueUninterpreted('changeControl', stage.policy?.changeControl)) {
return fail(
stageInstanceId,
'change_control_uninterpreted',
`scope declares change_control "${stage.policy.changeControl}", which this platform does not interpret`,
);
}
if (stage.policy?.changeControl && openGraph) {
const ccRow = await store.getStage(executionId, stageInstanceId).catch(() => null);
const ccAttempt = Number(ccRow?.attempt ?? priorStageRow?.attempt ?? 0);
Expand Down Expand Up @@ -3738,7 +3757,7 @@
// whether the stage's code predates its approval, so EVERY commit of the attempt
// must publish it — a repair commit that stayed silent left the lineage check
// judging the stage on its pre-approval commit.
const publishGitEvidence = async (result, { label = null } = {}) => {

Check warning on line 3760 in lambda/agentcore/commands/run-stage.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-shadow)

'result' is already declared in the upper scope.
if (!result || (!result.committed && result.ok)) return;
const failedRepos = (result.results ?? [])
.filter((r) => r.pushed !== true && r.pushed !== 'empty' && r.pushed !== 'up_to_date')
Expand All @@ -3748,7 +3767,7 @@
(r) =>
`${r.repo} (${r.reason ?? 'unknown'}${r.detail ? `: ${String(r.detail).slice(0, 300)}` : ''})`,
);
const scope = label ? `${stageLabel} (${label})` : stageLabel;

Check warning on line 3770 in lambda/agentcore/commands/run-stage.js

View workflow job for this annotation

GitHub Actions / lint

eslint(no-shadow)

'scope' is already declared in the upper scope.
const gitSummary = result.ok
? `Engine committed + pushed work for ${scope} (${(result.results ?? [])
.filter((r) => r.committed)
Expand Down
44 changes: 23 additions & 21 deletions lambda/agentcore/ensemble-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,33 +42,33 @@
import { dispatchPersona } from './persona-dispatch.js';
import { neutralizeTokens } from './stage-materializer.js';
import { evaluateGatePreconditions } from '../shared/gate-preconditions.js';
import {
ENSEMBLE_PROTOCOL_FILE,
SUPPORT_SESSION_MODES,
supportSessionRefs,
} from '../shared/v2-execution-plan.js';
import { contributionArtifactId } from '../shared/ensemble-contribution.js';

// The capability-registry handler ids (aidlc-capabilities.js RUNTIME_HANDLERS)
// this module implements. Checked against the registry by a test, so a
// capability cannot be declared handled without the code that handles it.
export const IMPLEMENTED_RUNTIME_HANDLERS = Object.freeze(['stage.mode.ensemble-sessions@v1']);
export const IMPLEMENTED_RUNTIME_HANDLERS = Object.freeze([
'policy.collaborators.off@v1',
'stage.mode.ensemble-sessions@v1',
]);

export { contributionArtifactId };

// The stage modes that get real per-persona sessions. `pipeline` and `mob` have
// separate support sessions; `subagent` does only when it declares supports. A
// lead-only `subagent` remains one session like `inline`.
export const SESSION_ENSEMBLE_MODES = Object.freeze(['pipeline', 'mob', 'subagent']);
// lead-only `subagent` remains one session like `inline`. The plan records which
// personas the collaborators switch withdrew from the SAME mode set, so the two
// read one definition.
export const SESSION_ENSEMBLE_MODES = SUPPORT_SESSION_MODES;

// The closure's protocol file distinguishes delegated single-session subagents
// from hub-and-spoke support sessions without relying on a release version.
export const ENSEMBLE_PROTOCOL_FILE = 'core/aidlc-common/protocols/stage-protocol-ensemble.md';

const subagentIsHubAndSpoke = (library) => {
const paths = library?.runtimeFilePaths;
// A library that carries no runtime-file list cannot say; only a release
// closure is ever resolved here, and the resolver always stamps the list.
if (paths == null) return true;
return Array.isArray(paths)
? paths.includes(ENSEMBLE_PROTOCOL_FILE)
: typeof paths.has === 'function' && paths.has(ENSEMBLE_PROTOCOL_FILE);
};
// Re-exported so the existing callers and tests keep one import site; the
// predicate that reads it lives with the plan, which is the other caller.
export { ENSEMBLE_PROTOCOL_FILE };

// Every cap is enforced on PERSISTED state, never an in-memory counter a resume
// would silently reset.
Expand Down Expand Up @@ -472,11 +472,13 @@ export const resolveEnsembleTopology = async ({
}) => {
if (!methodologyRelease) return null;
if (!stage?.policy) return null;
if (!SESSION_ENSEMBLE_MODES.includes(stage?.mode)) return null;
if (stage.mode === 'subagent' && !subagentIsHubAndSpoke(library)) return null;
const refs = (stage.supportAgentRefs ?? []).filter(
(ref) => ref && ref !== stage.agentRef && library?.agentsById?.[ref],
);
// The scope withdrew the collaborators: no topology at all, which is this
// runtime's single-session contract and upstream's own lead-only contract —
// the lead performs the synthesis and produces every declared output. A
// shortened roster would instead leave some declared outputs unowned.
if (stage.policy.collaborators === 'off') return null;
// The SAME eligibility question the plan's withdrawal audit asks.
const refs = supportSessionRefs({ stage, library });
if (refs.length === 0) return null;
const admitted = refs.slice(0, MAX_SUPPORT_PERSONAS);
const dropped = refs.slice(MAX_SUPPORT_PERSONAS);
Expand Down
8 changes: 8 additions & 0 deletions lambda/agentcore/stage-materializer.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { DEFAULT_CODEX_HOME_ROOT } from './cli/codex-store.js';
// capability cannot be declared handled without the code that handles it.
export const IMPLEMENTED_RUNTIME_HANDLERS = Object.freeze([
'agent.max-turns@v1',
'policy.project-type.default@v1',
'prompt.learnings@v1',
]);

Expand Down Expand Up @@ -261,6 +262,13 @@ export const renderScopePolicy = (policy) => {
' human can reopen the affected checkpoint.',
);
}
if (policy.existingCode === true) {
lines.push(
'- **Project type: BROWNFIELD.** This scope is authored for an existing',
' codebase: read what is already there before proposing anything new, and',
' keep the conventions you find.',
);
}
if (policy.skeleton === 'on') {
lines.push(
'- **Walking skeleton is ON.** For the first construction work, deliver the',
Expand Down
190 changes: 189 additions & 1 deletion lambda/agentcore/test/aidlc-release-adapters.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@
// catalog without the new fields must produce byte-identical output.

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { ANNEX_HANDLED_ENGINE_COMMANDS } from '../../shared/aidlc-capabilities.js';
import { INVOKE_FAMILY_FIDELITY } from '../../shared/aidlc-compatibility.js';
import {
INVOKE_FAMILY_FIDELITY,
filesFromCompatibilityFixture,
} from '../../shared/aidlc-compatibility.js';
import { buildReleaseBundle } from '../../shared/aidlc-release.js';
import { annexReachingBlocks } from '../../shared/annex-reach.js';
import { COMPOSER_AGENT_ID } from '../../shared/compose-match.js';
import { createSensorRunner } from '../sensor-runner.js';
import {
INVOKE_DIALECT_ANNEX,
Expand All @@ -18,6 +25,9 @@ import {
neutralizeInvoke,
renderScopePolicy,
} from '../stage-materializer.js';
import { buildComposePrompt } from '../commands/compose-plan-start.js';
import { composePersonaPrompt } from '../persona-dispatch.js';
import { __test as runStageInternals } from '../commands/run-stage.js';
import { workspaceRelativePath } from '../repo-paths.js';

const STAGE = Object.freeze({
Expand Down Expand Up @@ -214,6 +224,28 @@ describe('scope policy in the stage prompt', () => {
expect(rendered).toContain('request_plan_approval');
});

// The registry declares `SCOPE:existing_code` handled by a seam in this
// module, so the prompt must actually state the project type. Without this,
// removing the whole block leaves the plan and registry suites green and the
// "handled" claim is a comment.
it('states the brownfield project type an existing-code scope authors', () => {
const rendered = renderScopePolicy({ ...POLICY, existingCode: true });

expect(rendered).toContain('Project type: BROWNFIELD');
expect(
buildStagePrompt({
stage: { ...STAGE, policy: { ...POLICY, existingCode: true } },
stageBody: 'x',
}),
).toContain('Project type: BROWNFIELD');
});

it('says nothing about the project type when the scope does not author it', () => {
for (const existingCode of [null, false]) {
expect(renderScopePolicy({ ...POLICY, existingCode })).not.toContain('Project type');
}
});

it('says nothing about learnings when the scope turns them off', () => {
// `learnings: off` is enforced by WITHDRAWING the two tools in mcp/server.js,
// so the prompt must not describe tools the agent cannot see.
Expand Down Expand Up @@ -422,3 +454,159 @@ describe('sensor fire_on planes', () => {
]);
});
});

// `annex-reach.js` is a claim the PROMPT BUILDERS have to honour: the promotion
// guard stops blocking an engine command found in one of these blocks, so every
// session the runtime renders that block into must receive the answer. The
// builders and the guard share one module, but sharing a set is not the same as
// delivering it — a builder could gate on a part this set does not name, or stop
// gating on one it does. So rather than restate the rule, each case below walks
// the blocks of a REAL catalog and asserts the REAL prompt of each session that
// renders the block.
describe('annex reach, against the prompts the runtime really builds', () => {
const PROFILE = 'v2.9.0';
const { catalog } = buildReleaseBundle({
profileId: PROFILE,
files: filesFromCompatibilityFixture({
profileId: PROFILE,
fixture: JSON.parse(
readFileSync(
new URL(
`../../shared/test/fixtures/aidlc-compatibility/${PROFILE}.json`,
import.meta.url,
),
'utf8',
),
),
}),
});
const TOKEN_BODY = 'Run `{{INVOKE}} engine state practices-event --type x`.';
const STAGES = catalog.blocks.STAGE;
// These are CATALOG blocks (`block-mappers.js` `mapStage`), whose shape is
// `supportAgents` and a `reviewer` STRING naming the reviewing agent — not the
// resolved plan's `supportAgentRefs` / `reviewer.reviewerAgent`. Reading the
// plan's field names here leaves both sets empty and silently reduces the
// whole check to lead sessions, so the enumeration is pinned below.
const LEAD_AGENTS = new Set(STAGES.map((stage) => stage.leadAgent).filter(Boolean));
const SUPPORT_AGENTS = new Set(STAGES.flatMap((stage) => stage.supportAgents ?? []));
const REVIEWER_AGENTS = new Set(STAGES.map((stage) => stage.reviewer).filter(Boolean));
// The agentRef `loadMethodologyKnowledge` (commands/run-stage.js) renders into
// every session regardless of which agent runs it.
const SHARED_KNOWLEDGE_AGENT_REF = 'shared';
const label = (block) => `${block.type}:${block.id}`;

// One entry per session kind the runtime spawns, each rendering ONE authored
// part through the builder that session really uses. `part` is `persona` or
// `knowledge`: a release authors engine commands in both, and the guard counts
// them the same way.
const PROMPTS = Object.freeze({
lead: (part, body) =>
buildStagePrompt({
stage: STAGE,
stageBody: 'plain',
...(part === 'persona' ? { agentPersona: body } : { knowledge: body }),
}),
reviewer: (part, body) =>
runStageInternals.buildReviewerPrompt({
stage: { ...STAGE, reviewer: { artifact: 'business-logic-model' } },
reviewerAgent: 'aidlc-architecture-reviewer-agent',
reviewerPersona: part === 'persona' ? body : 'reviewer persona',
knowledge: part === 'knowledge' ? body : '',
round: 1,
}),
support: (part, body) => personaBrief('support', part, body),
link: (part, body) => personaBrief('link', part, body),
integrator: (part, body) => personaBrief('integrator', part, body),
composer: (part, body) =>
buildComposePrompt({
mode: 'initial',
persona: part === 'persona' ? body : '',
knowledge: part === 'knowledge' ? body : '',
}),
});
const personaBrief = (role, part, body) =>
composePersonaPrompt({
brief: `${role} brief`,
persona: part === 'persona' ? body : 'p',
knowledge: part === 'knowledge' ? body : '',
role,
});

// Which sessions render ONE claimed block, read off the real catalog. An agent
// that leads one stage is frequently a support persona or the reviewer in
// another, so these are not exclusive.
const sessionsRendering = (block) => {
if (block.type === 'STAGE') return ['lead'];
const agentRef = block.type === 'AGENT' ? block.blockId : block.agentRef;
if (agentRef === COMPOSER_AGENT_ID) return ['composer'];
if (agentRef === SHARED_KNOWLEDGE_AGENT_REF) {
return ['lead', 'reviewer', 'support', 'link', 'integrator'];
}
const sessions = [];
if (LEAD_AGENTS.has(agentRef)) sessions.push('lead');
if (REVIEWER_AGENTS.has(agentRef)) sessions.push('reviewer');
if (SUPPORT_AGENTS.has(agentRef)) sessions.push('support', 'link', 'integrator');
return sessions;
};

it('enumerates the catalog lead, support and reviewer agents, including dual-role ones', () => {
expect(LEAD_AGENTS.size).toBeGreaterThan(0);
expect(SUPPORT_AGENTS.size).toBeGreaterThan(0);
expect(REVIEWER_AGENTS.size).toBeGreaterThan(0);
const dualRole = [...LEAD_AGENTS].filter(
(ref) => SUPPORT_AGENTS.has(ref) || REVIEWER_AGENTS.has(ref),
);
expect(dualRole.length).toBeGreaterThan(0);
expect(sessionsRendering({ type: 'KNOWLEDGE', agentRef: dualRole[0] }).length).toBeGreaterThan(
1,
);
});

it('answers a claimed body in every session the runtime renders it into', () => {
const claimed = annexReachingBlocks({ blocks: catalog.blocks });
const checked = [];

for (const block of claimed) {
const sessions = sessionsRendering(block);
// A claimed body no session renders would make the guard's claim vacuous
// rather than wrong, and it would still be a hole: nothing would prove the
// answer arrives.
expect(sessions.length, label(block)).toBeGreaterThan(0);
const parts = block.type === 'STAGE' ? ['stageBody'] : ['persona', 'knowledge'];
for (const session of sessions) {
for (const part of parts) {
const prompt =
part === 'stageBody'
? buildStagePrompt({ stage: STAGE, stageBody: TOKEN_BODY })
: PROMPTS[session](part, TOKEN_BODY);
const where = `${label(block)}:${session}:${part}`;
expect(prompt, where).toContain(INVOKE_DIALECT_ANNEX);
expect(prompt, where).not.toContain('{{INVOKE}}');
checked.push(`${session}:${part}`);
}
}
}

// Every session kind had to be exercised, or the catalog stopped covering a
// role and the check quietly narrowed to the ones that remain.
expect([...new Set(checked.map((entry) => entry.split(':')[0]))].toSorted()).toEqual(
Object.keys(PROMPTS).toSorted(),
);
expect(checked.length).toBeGreaterThan(claimed.length);
});

// The other half of the claim: a body type NO builder gates on must stay out
// of the set, because a command invoked only there reaches a session with no
// answer and has to keep holding promotion.
it('leaves the block types no builder gates on out of the set', () => {
const claimedTypes = new Set(
annexReachingBlocks({ blocks: catalog.blocks }).map((b) => b.type),
);

expect([...claimedTypes].toSorted()).toEqual(['AGENT', 'KNOWLEDGE', 'STAGE']);
for (const type of ['RULE', 'SCOPE', 'ARTIFACT', 'SENSOR']) {
expect((catalog.blocks[type] ?? []).length, type).toBeGreaterThan(0);
expect(claimedTypes.has(type), type).toBe(false);
}
});
});
Loading
Loading