Skip to content
Draft
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
10 changes: 10 additions & 0 deletions src/lib/agent/__tests__/agent-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ describe('assemblePrompt', () => {
expect(customFn).toHaveBeenCalledWith(ctx);
});

it('sends the skill report through publish_handoff, not a report file', () => {
const prompt = assemblePrompt(makeRunDef(), {
...baseCtx,
skillPath: '/skills/test',
});

expect(prompt).toContain('publish_handoff');
expect(prompt).not.toContain('test-report.md');
});

it('omits custom and skill sections when not configured', () => {
const prompt = assemblePrompt(makeRunDef(), baseCtx);

Expand Down
9 changes: 8 additions & 1 deletion src/lib/agent/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { createCustomHeaders } from '@utils/custom-headers';
import type { HostResolution } from '@lib/host-resolution';
import { evaluateBashCommand } from './bash-fence';
import { createWizardToolsServer, WIZARD_TOOL_NAMES } from '@lib/wizard-tools';
import type { PublishHandoffContext } from '@lib/wizard-tools/handoff';
import {
createPreToolUseYaraHooks,
createPostToolUseYaraHooks,
Expand Down Expand Up @@ -184,6 +185,8 @@ export type AgentConfig = {
detectPackageManager: PackageManagerDetector;
/** Base URL for the skills server (context-mill dev or GitHub releases) */
skillsBaseUrl: string;
/** What `publish_handoff` needs to create the run's notebook. */
handoff?: PublishHandoffContext;
/** Feature flag key -> variant (evaluated at start of run). */
wizardFlags?: Record<string, string>;
wizardMetadata?: Record<string, string>;
Expand Down Expand Up @@ -599,6 +602,7 @@ export async function initializeAgent(
askMaxQuestions: config.askMaxQuestions,
orchestrator: config.orchestrator,
triageProvider,
handoff: config.handoff,
});
mcpServers['wizard-tools'] = wizardToolsServer;

Expand Down Expand Up @@ -1603,7 +1607,10 @@ function handleSDKMessage(
getUI().setDashboardUrl(dashboardMatch[1].trim());
}

// Check for [NOTEBOOK_URL] markers
// `publish_handoff` creates the notebook and sets the URL, so this
// marker is only still read for skills that haven't migrated yet and
// still upload a notebook themselves. Retire it once every released
// skill publishes through the tool.
const notebookRegex = new RegExp(
`${AgentSignals.NOTEBOOK_URL.replace(
/[.*+?^${}()|[\]\\]/g,
Expand Down
8 changes: 5 additions & 3 deletions src/lib/agent/agent-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,16 @@ Project context:
- PostHog Host: ${ctx.host.apiHost}`;
}

function skillPrompt(skillPath: string, reportFile: string): string {
function skillPrompt(skillPath: string): string {
return `A PostHog skill has been installed at ${skillPath}/. Read ${skillPath}/SKILL.md and follow its instructions completely.

After completing the skill workflow, write a brief markdown report to ./${reportFile} summarizing:
After completing the skill workflow, publish a brief markdown report with a single \`publish_handoff\` call, passing the complete report as \`content\`. Cover:
- What changes were made to the project
- Which files were modified or created
- Any manual steps the user should take next

That call creates the PostHog notebook the user reads the report in, so do not write the report to a file and do not create a notebook yourself.

Important: You must read a file immediately before attempting to write it, even if you have previously read it; failure to do so will cause a tool failure.`;
}

Expand All @@ -75,7 +77,7 @@ export function assemblePrompt(runDef: ProgramRun, ctx: PromptContext): string {

// Skill prompt (appended when a skill was pre-installed)
if (ctx.skillPath) {
parts.push(skillPrompt(ctx.skillPath, runDef.reportFile));
parts.push(skillPrompt(ctx.skillPath));
}

return parts.join('\n\n');
Expand Down
15 changes: 15 additions & 0 deletions src/lib/agent/runner/harness/anthropic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { createAioCapture } from '@lib/agent/aio-capture';
import { getLogFilePath, logToFile } from '@utils/debug';
import { detectNodePackageManagers } from '@lib/detection/package-manager';
import { sessionToOptions } from '@lib/agent/runner/shared/bootstrap';
import { buildHandoffContext } from '@lib/wizard-tools/handoff';
import type {
AgentResult,
AgentHarness,
Expand Down Expand Up @@ -65,6 +66,13 @@ export const anthropicBackend: AgentHarness = {
detectPackageManager:
config.detectPackageManager ?? detectNodePackageManagers,
skillsBaseUrl,
handoff: buildHandoffContext({
credentials,
installDir: session.installDir,
reportFile: programConfig.reportFile,
programId: programConfig.id,
programLabel: programConfig.description,
}),
wizardFlags,
wizardMetadata,
integrationLabel: config.integrationLabel,
Expand Down Expand Up @@ -140,6 +148,13 @@ export const anthropicBackend: AgentHarness = {
host: boot.credentials.host,
detectPackageManager: detectNodePackageManagers,
skillsBaseUrl: boot.skillsBaseUrl,
handoff: buildHandoffContext({
credentials: boot.credentials,
installDir: session.installDir,
reportFile: programConfig.reportFile,
programId: programConfig.id,
programLabel: programConfig.description,
}),
wizardFlags: boot.wizardFlags,
wizardMetadata: boot.wizardMetadata,
integrationLabel: programConfig.id,
Expand Down
18 changes: 15 additions & 3 deletions src/lib/agent/runner/harness/pi/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { AgentOutputSignals } from '@lib/agent/output-signals';
import { assembleCommandments } from '../../switchboard/commandments';
import { buildGatewayProvider, GATEWAY_PROVIDER } from './gateway';
import { createAioCapture } from '@lib/agent/aio-capture';
import { buildHandoffContext } from '@lib/wizard-tools/handoff';
import type {
AgentResult,
AgentHarness,
Expand Down Expand Up @@ -131,9 +132,13 @@ export function extractText(message: unknown): string {
}

/**
* Surface `[DASHBOARD_URL]` / `[NOTEBOOK_URL]` markers the agent prints (after
* the MCP creates them) into the outro link, mirroring the anthropic path's
* signal parsing (#9). The marker carries the URL the MCP returned.
* Surface the `[DASHBOARD_URL]` marker the agent prints (after the MCP creates
* the dashboard) into the outro link, mirroring the anthropic path's signal
* parsing (#9). The marker carries the URL the MCP returned.
*
* `[NOTEBOOK_URL]` is read for the same reason it still exists: `publish_handoff`
* sets the notebook URL itself, but skill releases that upload their own notebook
* only have the marker. Drop it once none do.
*/
export function applyOutroMarkers(textBlock: string): void {
const markers: Array<[string, (url: string) => void]> = [
Expand Down Expand Up @@ -381,6 +386,13 @@ export const piBackend: AgentHarness = {
workingDirectory: session.installDir,
skillsBaseUrl: boot.skillsBaseUrl,
triageProvider: boot.triageProvider,
handoff: buildHandoffContext({
credentials: boot.credentials,
installDir: session.installDir,
reportFile: programConfig.reportFile,
programId: programConfig.id,
programLabel: programConfig.description,
}),
detectPackageManager: config.detectPackageManager,
// The host ask bridge — lets interactive programs (self-driving) ask
// the user through pi. Threaded from the runner, same path as the
Expand Down
20 changes: 16 additions & 4 deletions src/lib/agent/runner/harness/pi/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,16 +289,28 @@ export async function runPiTask(inputs: TaskRunInputs): Promise<AgentResult> {
.map(([, make]) => make());

// Wizard env + package-manager tools are always on — their handlers are
// fenced, and init/build tasks depend on them.
// fenced, and init/build tasks depend on them. publish_handoff rides
// along so the report task can publish the handoff and create its notebook.
const { createWizardPiTools } = await import('./tools');
const { buildHandoffContext } = await import('@lib/wizard-tools/handoff');
const wizardTools = createWizardPiTools({
workingDirectory: dir,
handoff: buildHandoffContext({
credentials: boot.credentials,
installDir: dir,
reportFile: programConfig.reportFile,
programId: programConfig.id,
programLabel: programConfig.description,
}),
skillsBaseUrl: boot.skillsBaseUrl,
triageProvider: boot.triageProvider,
}).filter((t) =>
['check_env_keys', 'set_env_values', 'detect_package_manager'].includes(
t.name,
),
[
'check_env_keys',
'set_env_values',
'detect_package_manager',
'publish_handoff',
].includes(t.name),
);

const { createPiOrchestratorTools } = await import('./orchestrator-tools');
Expand Down
33 changes: 33 additions & 0 deletions src/lib/agent/runner/harness/pi/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ import {
} from '@lib/wizard-tools/tools';
import type { LLMProvider } from '@posthog/warlock';
import { isFullyCancelled, type WizardAskBridge } from '@lib/wizard-ask-bridge';
import {
PUBLISH_HANDOFF_CONTENT_DESCRIPTION,
PUBLISH_HANDOFF_DESCRIPTION,
PUBLISH_HANDOFF_TOOL_NAME,
publishHandoff,
type PublishHandoffContext,
} from '@lib/wizard-tools/handoff';
import { createSecretVault } from '@lib/secret-vault';
import { withMode } from './index';
import {
Expand Down Expand Up @@ -62,6 +69,11 @@ export interface PiToolsContext {
disallowedTools?: readonly string[];
/** Scan-triage classifier, resolved once in bootstrap. Absent → scans fail closed. */
triageProvider?: LLMProvider;
/**
* What `publish_handoff` needs to create the notebook. Absent → the handoff is
* still stored on the session and only the notebook step is skipped.
*/
handoff?: PublishHandoffContext;
}

export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] {
Expand Down Expand Up @@ -381,12 +393,33 @@ export function createWizardPiTools(ctx: PiToolsContext): ToolDefinition[] {
},
});

// Native mirror of the MCP `publish_handoff` tool (shared description, so no drift).
const publishHandoffTool = defineTool({
name: PUBLISH_HANDOFF_TOOL_NAME,
label: 'Publish handoff',
description: PUBLISH_HANDOFF_DESCRIPTION,
promptSnippet:
'publish_handoff(content) — publish the full report markdown to the wizard session and create its notebook',
parameters: Type.Object({
content: Type.String({
description: PUBLISH_HANDOFF_CONTENT_DESCRIPTION,
}),
}),
async execute(_id, args) {
const result = await publishHandoff(args.content, ctx.handoff);
logToFile(`[pi] publish_handoff: ${result.message}`);
return text(result.message);
},
});

const tools = [
loadSkillMenu,
installSkill,
checkEnvKeys,
setEnvValues,
detectPm,
// Sequential: it mutates the store's handoff state.
withMode(publishHandoffTool, 'sequential'),
];
// Register wizard_ask only when the program allows it. posthog-integration
// disallows it (runs without structured user input); self-driving keeps it.
Expand Down
5 changes: 4 additions & 1 deletion src/lib/agent/runner/sequence/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,12 +280,15 @@ export async function runLinearProgram(
// the session reference — direct mutation then lands on a stale snapshot
// that the screen never reads. UI.setOutroData() goes through the store
// and also merges in any post-snapshot URLs from the live session.
//
// No `reportFile` here: `config.reportFile` is only the fallback filename
// `publish_handoff` would use, and the file exists solely when that fallback
// fired. The merge above carries it in on those runs.
const outroData = config.buildOutroData
? config.buildOutroData(session, credentials)
: {
kind: OutroKind.Success,
message: config.successMessage,
reportFile: config.reportFile,
docsUrl: config.docsUrl,
continueUrl: session.signup
? `${host.appHost}/products?source=wizard`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,12 +610,6 @@ export async function runOrchestrator(
? store.readHandoff(reviewTask.id)?.conflict
: undefined;

// Prefer the report the run wrote; fall back to the raw queue if it is missing.
const reportPath = path.join(session.installDir, 'posthog-setup-report.md');
const reportFile = existsSync(reportPath)
? 'posthog-setup-report.md'
: store.queuePath;

// Not-needed tasks were never work, so they leave the denominator too.
const notRequired = summary[TaskStatus.Skipped];

Expand Down Expand Up @@ -655,9 +649,8 @@ export async function runOrchestrator(
kind: OutroKind.Success,
message,
body: conflict
? `⚠ Build conflict: ${conflict}\nFull details are in the report.`
? `⚠ Build conflict: ${conflict}\nFull details are in the setup report.`
: undefined,
reportFile,
docsUrl: 'https://posthog.com/docs/ai-engineering/ai-wizard',
});
getUI().outro(message);
Expand Down
7 changes: 4 additions & 3 deletions src/lib/agent/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ export const AgentSignals = {
*/
DASHBOARD_URL: '[DASHBOARD_URL]',
/**
* Signal emitted when the agent has uploaded a report to a PostHog
* notebook. Format: `[NOTEBOOK_URL] <full https url>`. The URL is captured
* onto `session.notebookUrl` and surfaced by programs in their outro.
* Legacy notebook signal: `[NOTEBOOK_URL] <full https url>`. `publish_handoff`
* creates the notebook and sets the URL itself, so nothing emits this any
* more — it stays read-only for skill releases that still upload their own
* notebook, and can be retired once none do.
*/
NOTEBOOK_URL: '[NOTEBOOK_URL]',
} as const;
Expand Down
6 changes: 3 additions & 3 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,9 @@ export const DUMMY_PROJECT_API_KEY = '_YOUR_POSTHOG_PROJECT_TOKEN_';
* - query:read run HogQL queries when the agent needs data
* - dashboard:write create the onboarding dashboard during setup
* - insight:write create the onboarding insights during setup
* - notebook:write upload the events-audit report as a PostHog notebook
* in step 6 of the events-audit skill (notebooks-create
* MCP tool requires this scope)
* - notebook:write create the notebook that carries every program's
* report — the wizard's own `publish_handoff` tool posts
* it, and without this scope the call 403s
* - event_definition:write
* create event definitions from the completed wizard
* session's event plan
Expand Down
17 changes: 8 additions & 9 deletions src/lib/programs/__tests__/source-maps-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,26 @@ const baseParams = {
host: 'https://us.i.posthog.com',
settingsUrl: 'https://us.posthog.com/settings/user-api-keys',
uiHost: 'https://us.posthog.com',
reportFile: 'posthog-source-maps-report.md',
};

describe('buildSourceMapsUploadPrompt hand-off report', () => {
it('instructs the agent to write the report file the outro points at', () => {
it('instructs the agent to publish the hand-off with publish_handoff', () => {
const prompt = buildSourceMapsUploadPrompt(baseParams);

expect(prompt).toContain('Write the hand-off to');
expect(prompt).toContain('`posthog-source-maps-report.md`');
expect(prompt).toContain('`publish_handoff`');
expect(prompt).toContain('`content`');
});

it('pins the report to the wizard working directory for monorepo projects', () => {
// The outro resolves reportFile against installDir, so a `backend/`
// scoped run must still write the report at the working directory.
it('never tells the agent to write the report to a file', () => {
// The report reaches the user as a notebook; a report file only exists
// when publish_handoff's own fallback fires, which the agent never drives.
const prompt = buildSourceMapsUploadPrompt({
...baseParams,
projectPath: 'backend',
});

expect(prompt).toContain("WIZARD'S WORKING DIRECTORY");
expect(prompt).toContain('never prefixed with the selected');
expect(prompt).toContain('do not write the report to a file');
expect(prompt).not.toContain('posthog-source-maps-report.md');
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/lib/programs/__tests__/warehouse-suggestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ describe('outro suggestion', () => {

expect(outro.message).toBe('Successfully installed PostHog!');
expect(outro.changes).toContain('Added PostHog provider');
expect(outro.reportFile).toBe('posthog-setup-report.md');
// No report file — the report goes out via publish_handoff + notebook.
expect(outro.reportFile).toBeUndefined();
}
});
});
Expand Down
11 changes: 8 additions & 3 deletions src/lib/programs/ai-observability/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ This flow has no pre-installed skill — you install the right one yourself:
packages, wire OTel, set env vars, and describe verification.

Make only additive changes — do not touch existing PostHog init, identify
calls, event capture, or dashboards. Those belong to other skills. The
final report is written to ./${AI_OBSERVABILITY_REPORT_FILE}.`,
successMessage: `AI Observability configured! View the report at ./${AI_OBSERVABILITY_REPORT_FILE}`,
calls, event capture, or dashboards. Those belong to other skills.

Publish the final report with a single \`publish_handoff\` call, passing the
complete report markdown as \`content\`. That call creates the PostHog notebook
the user reads it in, so do not write the report to a file and do not create a
notebook yourself.`,
successMessage:
'AI Observability configured! Your report is in a PostHog notebook.',
reportFile: AI_OBSERVABILITY_REPORT_FILE,
docsUrl: 'https://posthog.com/docs/ai-observability',
spinnerMessage: 'Setting up AI Observability...',
Expand Down
Loading