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
22 changes: 22 additions & 0 deletions e2e-harness/__tests__/e2e-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ import { OutroKind, RunPhase } from '@lib/wizard-session';
import type { AskQuestion, WizardSession } from '@lib/wizard-session';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import { Overlay } from '@ui/tui/router';
import { TASK_OUTCOMES_KEY } from '@lib/agent/runner/sequence/orchestrator/queue';
import {
E2eRunRecorder,
abortReasonFrom,
buildE2eResult,
detectedSourcesFrom,
readReportFile,
taskOutcomesFrom,
} from '../e2e-result';
import { DEFAULT_E2E_PROFILE, decideE2eAction } from '../e2e-profile';
import type { CiState } from '../wizard-ci-driver';
Expand Down Expand Up @@ -422,6 +424,9 @@ describe('buildE2eResult', () => {
matchedSignal: 'found DATABASE_URL',
},
],
[TASK_OUTCOMES_KEY]: [
{ type: 'ai-observability', status: 'not needed', optional: true },
],
},
outroData: null,
},
Expand All @@ -447,6 +452,7 @@ describe('buildE2eResult', () => {
'runPhase',
'screenPath',
'skillsComplete',
'taskOutcomes',
'tasks',
'unansweredAsks',
].sort(),
Expand All @@ -463,6 +469,22 @@ describe('buildE2eResult', () => {
]);
});

it("reports the queue's terminal outcomes by type", () => {
expect(build().taskOutcomes).toEqual([
{ type: 'ai-observability', status: 'not needed', optional: true },
]);
});

it('distinguishes never-recorded from an orchestrator run with no tasks', () => {
// Linear runs (or a run that died pre-drain) never set the key → null,
// and the payload drops the field; an orchestrator run that drained an
// empty queue records [] — graders must not conflate the two.
expect(taskOutcomesFrom({ frameworkContext: {} })).toBeNull();
expect(
taskOutcomesFrom({ frameworkContext: { [TASK_OUTCOMES_KEY]: [] } }),
).toEqual([]);
});

it('reports the sources detection found', () => {
expect(build().detectedSources).toEqual([
{
Expand Down
27 changes: 27 additions & 0 deletions e2e-harness/e2e-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
import fs from 'fs';
import path from 'path';
import { OutroKind, type WizardSession } from '@lib/wizard-session';
import {
TASK_OUTCOMES_KEY,
type TaskOutcome,
} from '@lib/agent/runner/sequence/orchestrator/queue';
import { DETECTED_WAREHOUSE_SOURCES_KEY } from '@lib/programs/warehouse-source/detect';
import type { DetectedSource } from '@lib/warehouse-sources/types';
import type { E2eDecisionReport } from './e2e-profile.js';
Expand Down Expand Up @@ -219,6 +223,22 @@ export function detectedSourcesFrom(
return Array.isArray(raw) ? (raw as DetectedSource[]) : [];
}

/**
* The drained queue's final outcomes the orchestrator wrote into
* frameworkContext. Unlike the UI `tasks` rows (label + display status, where
* done and failed both render `completed`), these carry the task `type` and
* the queue's real terminal status — the stable vocabulary e2e expectations
* assert on. Null when nothing was recorded (a linear run, or a run that died
* before the drain settled) — distinct from an orchestrator run whose queue
* held no tasks, which records `[]`.
*/
export function taskOutcomesFrom(
session: Pick<WizardSession, 'frameworkContext'>,
): TaskOutcome[] | null {
const raw = session.frameworkContext[TASK_OUTCOMES_KEY];
return Array.isArray(raw) ? (raw as TaskOutcome[]) : null;
}

/** The keys the result payload carried before the warehouse work. */
export interface E2eResultBase {
runPhase: string;
Expand Down Expand Up @@ -248,6 +268,13 @@ export function buildE2eResult(args: {
refusedAsks: recorder.refusedAsks,
notices: recorder.notices,
tasks: tasks.map((t) => ({ label: t.label, status: t.status })),
// Key absent = never recorded (linear run); present-but-empty = an
// orchestrator run that drained no tasks. Graders must not conflate them.
taskOutcomes: taskOutcomesFrom(session)?.map((t) => ({
type: t.type,
status: t.status,
optional: t.optional,
})),
detectedSources: detectedSourcesFrom(session).map((s) => ({
kind: s.kind,
label: s.label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Below are important guidance on the harness constraints you are bound to. Follow
- If a \`bash\` command is blocked, do NOT retry it or a reworded variant — the fence is deterministic and will block it again. Change approach: inspect with \`read\`/\`grep\`, fix the \`edit\` and continue, or skip a step that is not essential. Retrying blocked commands only wastes turns.
- If you get stuck on something outside your control — a package install that keeps failing, a command you are not permitted to run, or a fix outside the scope of this integration — do NOT spiral retrying it. Note it in the setup report for the user to resolve, and move on with the rest of the work.
- A \`[YARA]\` block from the security scanner is on YOUR side — it caught a real problem in the edit you just tried (PII in a \`capture()\`, a hardcoded secret or host URL). Read the block reason, understand exactly what it flagged, and change the CODE to comply — e.g. a PII block means move that field off the event and onto the person via \`identify()\`/\`$set\`, keeping the event itself. Retrying the same edit will just block again, and dropping the step loses the instrumentation — so fix it to satisfy the scanner, then continue.
- Call \`load_skill_menu\` once to choose the skill, then \`install_skill\`. Do not call \`load_skill_menu\` again this session.
- Call \`load_skill_menu\` once per category needed by the workflow, then \`install_skill\` with the chosen ID. Reuse a category’s menu instead of loading it again.
- Follow the skill's steps in order. Finish the SDK setup — install it, import it at the top of the module, and INITIALIZE it at the framework's entry point for every runtime the integration targets (typically both client and server) — BEFORE adding any event capture. A capture against an uninitialized SDK silently no-ops, so initialization comes first. If you're stuck and cannot install an SDK, add capture calls and add a clear note at the top of the integration report. Never guard a capture behind a runtime "if the SDK happens to be installed" check or a dynamic \`require\`; that ships an uninitialized SDK and no events fire. Do not jump ahead to the fix/revise step just to get a build passing.
- Never write a PostHog URL or token as a literal in source (e.g. 'https://us.i.posthog.com') — it is blocked. Read them from environment variables (process.env.POSTHOG_HOST, os.environ['POSTHOG_HOST'], etc.).
- To inspect or change a project's \`.env\` files, go straight to the wizard-tools MCP: \`check_env_keys\` to see which keys are present, \`set_env_values\` to write them. A plain \`read\`, \`edit\`, or \`write\` of any \`.env*\` file is blocked — reach for those tools first rather than discovering the block.
Expand Down Expand Up @@ -192,7 +192,7 @@ Below are important guidance on the harness constraints you are bound to. Follow
- If a \`bash\` command is blocked, do NOT retry it or a reworded variant — the fence is deterministic and will block it again. Change approach: inspect with \`read\`/\`grep\`, fix the \`edit\` and continue, or skip a step that is not essential. Retrying blocked commands only wastes turns.
- If you get stuck on something outside your control — a package install that keeps failing, a command you are not permitted to run, or a fix outside the scope of this integration — do NOT spiral retrying it. Note it in the setup report for the user to resolve, and move on with the rest of the work.
- A \`[YARA]\` block from the security scanner is on YOUR side — it caught a real problem in the edit you just tried (PII in a \`capture()\`, a hardcoded secret or host URL). Read the block reason, understand exactly what it flagged, and change the CODE to comply — e.g. a PII block means move that field off the event and onto the person via \`identify()\`/\`$set\`, keeping the event itself. Retrying the same edit will just block again, and dropping the step loses the instrumentation — so fix it to satisfy the scanner, then continue.
- Call \`load_skill_menu\` once to choose the skill, then \`install_skill\`. Do not call \`load_skill_menu\` again this session.
- Call \`load_skill_menu\` once per category needed by the workflow, then \`install_skill\` with the chosen ID. Reuse a category’s menu instead of loading it again.
- Follow the skill's steps in order. Finish the SDK setup — install it, import it at the top of the module, and INITIALIZE it at the framework's entry point for every runtime the integration targets (typically both client and server) — BEFORE adding any event capture. A capture against an uninitialized SDK silently no-ops, so initialization comes first. If you're stuck and cannot install an SDK, add capture calls and add a clear note at the top of the integration report. Never guard a capture behind a runtime "if the SDK happens to be installed" check or a dynamic \`require\`; that ships an uninitialized SDK and no events fire. Do not jump ahead to the fix/revise step just to get a build passing.
- Never write a PostHog URL or token as a literal in source (e.g. 'https://us.i.posthog.com') — it is blocked. Read them from environment variables (process.env.POSTHOG_HOST, os.environ['POSTHOG_HOST'], etc.).
- To inspect or change a project's \`.env\` files, go straight to the wizard-tools MCP: \`check_env_keys\` to see which keys are present, \`set_env_values\` to write them. A plain \`read\`, \`edit\`, or \`write\` of any \`.env*\` file is blocked — reach for those tools first rather than discovering the block.
Expand Down
57 changes: 54 additions & 3 deletions src/lib/agent/__tests__/agent-prompt-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,17 @@ Connect the sources.

it('marks the sink and the runner-seeded task from frontmatter', () => {
const p = parseAgentPrompt(
'---\nsink: true\nrunnerSeeded: true\n---\nx',
'---\nsink: true\nrunnerSeeded: true\noptional: true\n---\nx',
't',
);
expect(p.sink).toBe(true);
expect(p.runnerSeeded).toBe(true);
expect(p.optional).toBe(true);

const plain = parseAgentPrompt('---\nmodel: x\n---\nx', 't');
expect(plain.sink).toBe(false);
expect(plain.runnerSeeded).toBe(false);
expect(plain.optional).toBe(false);
});

it('defaults missing array fields to empty and models to undefined', () => {
Expand Down Expand Up @@ -212,6 +214,7 @@ describe('buildRegistry', () => {
seed: false,
sink: false,
runnerSeeded: false,
optional: false,
skills: [],
allowedTools: [],
disallowedTools: [],
Expand Down Expand Up @@ -243,15 +246,17 @@ describe('buildRegistry', () => {
prompt({ type: 'plan', flow: 'f', seed: true }),
prompt({ type: 'install', flow: 'f' }),
prompt({ type: 'warehouse', flow: 'f', runnerSeeded: true }),
prompt({ type: 'logs', flow: 'f', optional: true }),
prompt({ type: 'report', flow: 'f', sink: true }),
],
'f',
);

// The type still runs — it is only the planner that cannot reach it.
expect(registry.types).toEqual(['install', 'warehouse', 'report']);
expect(registry.enqueueableTypes).toEqual(['install', 'report']);
expect(registry.types).toEqual(['install', 'warehouse', 'logs', 'report']);
expect(registry.enqueueableTypes).toEqual(['install', 'logs', 'report']);
expect(registry.runnerSeededTypes).toEqual(['warehouse']);
expect(registry.optionalTypes).toEqual(['logs']);
expect(registry.sinkTypes).toEqual(['report']);
});

Expand All @@ -267,6 +272,21 @@ describe('buildRegistry', () => {
expect(buildRegistry(prompts, 'f').types).toEqual(['build', 'dashboard']);
});

it('reports only excluded types the flow actually had, deduped', () => {
const prompts = [
prompt({ type: 'plan', flow: 'f', seed: true }),
prompt({ type: 'build', flow: 'f' }),
prompt({ type: 'logs', flow: 'f' }),
];
const registry = buildRegistry(prompts, 'f', {
// 'logs' twice (two exclusion sources overlap) and a type this flow
// never carried — the note must name 'logs' once and nothing else.
exclude: ['logs', 'logs', 'ghost-type'],
});
expect(registry.excludedTypes).toEqual(['logs']);
expect(buildRegistry(prompts, 'f').excludedTypes).toEqual([]);
});

it('bakes stage overrides into the pi frontmatter at load; unnamed stages and sdk fields untouched', () => {
const prompts = [
prompt({
Expand Down Expand Up @@ -322,6 +342,7 @@ describe('resolveTask', () => {
seed: false,
sink: false,
runnerSeeded: false,
optional: false,
modelPi: 'openai/gpt-5.6-luna',
effortPi: 'low',
modelSdk: 'claude-haiku-4-5',
Expand Down Expand Up @@ -560,6 +581,36 @@ describe('renderToolInventory', () => {
});

describe('assembleSeedPrompt', () => {
it('is byte-identical to a no-exclusions prompt when nothing is excluded', () => {
const ctx = {
projectId: 1,
projectApiKey: 'k',
host: { apiHost: 'https://h' },
} as Parameters<typeof assembleSeedPrompt>[0];

expect(assembleSeedPrompt(ctx, 'plan it', [], [])).toBe(
assembleSeedPrompt(ctx, 'plan it', []),
);
expect(assembleSeedPrompt(ctx, 'plan it')).not.toContain('excludes them');
});

it('names excluded types so the plan mentioning them reads as a skip', () => {
const ctx = {
projectId: 1,
projectApiKey: 'k',
host: { apiHost: 'https://h' },
} as Parameters<typeof assembleSeedPrompt>[0];

const prompt = assembleSeedPrompt(
ctx,
'plan it',
[],
['ai-observability', 'logs'],
);
expect(prompt).toContain('this run excludes them: ai-observability, logs');
expect(prompt).toContain('do not queue them, do not retry them');
});

it('names the tasks the wizard queued before the planner ran', () => {
const ctx = {
projectId: 1,
Expand Down
42 changes: 40 additions & 2 deletions src/lib/agent/agent-prompt-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,21 @@ export function renderToolInventory(toolNames: readonly string[]): string {

const TASK_BASICS = `You are one step in a larger PostHog workflow made of several tasks, run as a fresh agent with no memory of the other tasks beyond the context you are given. Other tasks — before and after you — own the rest of the work, so stay strictly on your own task: do not do a neighbouring step's job, redo what an upstream handoff already did, or reach beyond what you were asked. Do only your task, then report exactly once by calling complete_task with a structured handoff: what your goal was, what you did, and what the next agent should know. When you are given context from previous steps, trust it — those agents already did their work, so do not re-verify or re-read what their handoffs tell you. Build on it and move fast. Read a file before you edit it, so your own changes do not duplicate what is already there. Work only inside this project's own directory: never read, list, or search (find, ls, grep, glob) outside it — not the OS, not other projects, not global package caches. If your task seems to need something outside this directory, it does not — skip that part and say so in your handoff rather than hunting across the filesystem. If your task does not apply to this project — there is genuinely nothing for it to do — report it with status \`not needed\` and say why, rather than marking it done.`;

const SEED_BASICS = `You are the orchestrator. Plan the work and seed the queue with enqueue_task — each call returns an id you can pass as a dependency to a later task. Give each task a short label for the UI — the action in a few words, not file names, class names, or other specifics. The last task you queue, the one that reports on the run, must depend on every other task in the queue — directly, or through a task it already depends on. You are not a task yourself: do not call complete_task and do not edit the project.`;
const SEED_BASICS = `You are the orchestrator. Plan the work and seed the queue with enqueue_task — each call names the task kind in its \`type\` field (the tool has no \`task\` field) and returns an id you can pass as a dependency to a later task. Give each task a short label for the UI — the action in a few words, not file names, class names, or other specifics. The last task you queue, the one that reports on the run, must depend on every other task in the queue — directly, or through a task it already depends on. You are not a task yourself: do not call complete_task and do not edit the project.`;

/**
* Injected only when the run excludes task types (a flag or CI gate), so the
* seed prompt of an ordinary run is byte-identical to one with no exclusions.
* Naming the types beats a "check the tool's list" rule: the planner has
* nothing to infer, and the pre-queued (runner-seeded) tasks — absent from the
* enqueue list by design — cannot be mistaken for exclusions.
*/
function excludedTypesNote(types: readonly string[]): string | null {
if (types.length === 0) return null;
return `The plan below may mention these task types, but this run excludes them: ${types.join(
', ',
)}. enqueue_task will not accept them — do not queue them, do not retry them, and hang nothing off them.`;
}
Comment on lines +101 to +106

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if excluding tasks from planner agent, include this prompt

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Mannnn I can't wait for Jev. Jev would be the perfect enqueue agent and prompt assembler

@gewenyu99 gewenyu99 Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Here's the potential issue: The prompt tells the planner that task types were excluded even when they weren’t available in this flow. Overlapping exclusions can also list the same type twice.

flag excludes logs -> this flow has no logs task to remove -> prompt still tells the planner logs was excluded -> planner may describe work as skipped that was never available

Suggested fix: Build the note from task types actually removed from the flow, and list each type once.


/**
* Tasks the wizard queued before the planner ran. It has to see them: they are
Expand Down Expand Up @@ -147,8 +161,15 @@ export function assembleSeedPrompt(
ctx: OrchestratorPromptContext,
body: string,
preQueued: readonly { id: string; type: string }[] = [],
excludedTypes: readonly string[] = [],
): string {
return [projectContext(ctx), SEED_BASICS, preQueuedTasks(preQueued), body]
return [
projectContext(ctx),
SEED_BASICS,
excludedTypesNote(excludedTypes),
preQueuedTasks(preQueued),
body,
]
.filter(Boolean)
.join('\n\n');
}
Expand Down Expand Up @@ -214,6 +235,8 @@ export interface AgentPrompt {
* one an agent can reach — it can neither invent the task nor forget it.
*/
runnerSeeded: boolean;
/** Marks a supplementary task: terminal failure unblocks dependents and never fails the run. */
optional: boolean;
/** Per-harness model/effort; both profiles also require gateway admission and prompt compatibility. */
modelPi?: string;
effortPi?: ThinkingLevel;
Expand Down Expand Up @@ -258,6 +281,15 @@ export interface AgentRegistry {
readonly sinkTypes: string[];
/** The types only the wizard queues, from what it detected before the run. */
readonly runnerSeededTypes: string[];
/** The types whose terminal failure must not block dependents or fail the run. */
readonly optionalTypes: string[];
/**
* The excluded types this flow actually had, deduped — the honest list for
* the seed prompt's exclusion note. An exclusion naming a type the flow
* never carried is dropped: telling the planner it was "excluded" would
* describe work that was never available.
*/
readonly excludedTypes: string[];
/** The flow's planner, the one prompt marked `seed: true` in its frontmatter. */
readonly seed?: AgentPrompt;
get(type: string): AgentPrompt | undefined;
Expand Down Expand Up @@ -293,11 +325,16 @@ export function buildRegistry(
});
const byType = new Map(inFlow.map((p) => [p.type, p]));
const tasks = inFlow.filter((p) => !p.seed);
const flowTypes = new Set(
prompts.filter((p) => p.flow === flow && !p.seed).map((p) => p.type),
);
return {
types: tasks.map((p) => p.type),
enqueueableTypes: tasks.filter((p) => !p.runnerSeeded).map((p) => p.type),
sinkTypes: tasks.filter((p) => p.sink).map((p) => p.type),
runnerSeededTypes: tasks.filter((p) => p.runnerSeeded).map((p) => p.type),
optionalTypes: tasks.filter((p) => p.optional).map((p) => p.type),
excludedTypes: [...excluded].filter((t) => flowTypes.has(t)),
seed: inFlow.find((p) => p.seed),
get: (type) => byType.get(type),
};
Expand Down Expand Up @@ -391,6 +428,7 @@ export function parseAgentPrompt(
seed: fields.seed === 'true',
sink: fields.sink === 'true',
runnerSeeded: fields.runnerSeeded === 'true',
optional: fields.optional === 'true',
modelPi: str(fields.model_pi),
effortPi: effort(fields.effort_pi, 'effort_pi'),
modelSdk: str(fields.model_sdk),
Expand Down
Loading
Loading