Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/experiment-ai-tool.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Register the createExperiment tool with its validated request schema for AI integrations.
5 changes: 5 additions & 0 deletions .changeset/small-experiments-chat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Run experiments and optimizations from AI chat with progress, cancellation, and a link to metric distributions. Keep the conversation open while inspecting experiments.
3 changes: 2 additions & 1 deletion apps/brunch-agent/test/compiler-feedback.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ import { openBrowserFixture } from "./browser-fixture.ts";
import { browserResultFrom } from "./browser-result.ts";
import { nativeSchemaProvider } from "./native-schema-provider.ts";

const cleanCompilation = "No errors or warnings found in net function code.";
const cleanCompilation =
"No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.";
const output = mkdtempSync(join(tmpdir(), "m7c-compiler-feedback-"));
const save = (name: string, value: unknown) =>
writeFileSync(join(output, `${name}.json`), JSON.stringify(value, null, 2));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ test.skipIf(!enabled)(
expect(summary.mode).toBe("batched-construction");
expect(summary.dirtyCompilation).toContain("definitelyNotDefined");
expect(summary.cleanCompilation).toBe(
"No errors or warnings found in net function code.",
"No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.",
);
expect(summary.repairHash).toMatch(/^[a-f0-9]{64}$/u);
expect(summary.layoutHash).toMatch(/^[a-f0-9]{64}$/u);
Expand Down
5 changes: 3 additions & 2 deletions apps/brunch-agent/test/root-creation.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ try {
save("compilation", result);
assert.equal(
result.output,
"No errors or warnings found in net function code.",
"No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.",
);
return text("Native creation and canonical check completed.");
}),
Expand Down Expand Up @@ -504,7 +504,8 @@ try {
requests: contexts.length,
applied: records.length,
schemaClasses: observedNodeMutationNames,
compilation: "No errors or warnings found in net function code.",
compilation:
"No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.",
scope:
"Same-session synthetic creation/correction only; reopen assertion follows.",
});
Expand Down
2 changes: 1 addition & 1 deletion apps/brunch-agent/test/typed-state.integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ try {
compilations.push(result);
assert.equal(
result.output,
"No errors or warnings found in net function code.",
"No errors or warnings found in net function code. Scenario and metric compilation is checked when creating an experiment.",
"Final corrected net must report clean canonical diagnostics; no scenario execution follows",
);
return text(
Expand Down
7 changes: 7 additions & 0 deletions libs/@hashintel/petrinaut-core/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
mutationActionInputSchemas,
type MutationActionName,
} from "./action-schemas";
import { petrinautExperimentRequestSchema } from "./ai/experiments";
import {
aiCommandActionInputSchemas,
type AiCommandActionName,
Expand Down Expand Up @@ -93,6 +94,7 @@ export const getLatestNetDefinitionToolName = "getLatestNetDefinition";
export const getNetCompilationErrorsToolName = "getNetCompilationErrors";
export const setNetTitleToolName = "setNetTitle";
export const readPetrinautDocToolName = "readPetrinautDoc";
export const createExperimentToolName = "createExperiment";

export const petrinautDocNames = [
"drawing-a-net",
Expand Down Expand Up @@ -182,6 +184,7 @@ export const petrinautAiToolInputSchemas = {
[getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema,
[setNetTitleToolName]: setNetTitleToolInputSchema,
[readPetrinautDocToolName]: readPetrinautDocToolInputSchema,
[createExperimentToolName]: petrinautExperimentRequestSchema,
};

export const petrinautAiMutationTools = createToolBundle(
Expand Down Expand Up @@ -211,6 +214,10 @@ export const petrinautAiTools = {
description: getSchemaDescription(readPetrinautDocToolInputSchema),
inputSchema: readPetrinautDocToolInputSchema,
},
[createExperimentToolName]: {
description: getSchemaDescription(petrinautExperimentRequestSchema),
inputSchema: petrinautExperimentRequestSchema,
},
} satisfies PetrinautAiTools;

export type PetrinautAiToolName = keyof typeof petrinautAiTools;
Expand Down
1 change: 1 addition & 0 deletions libs/@hashintel/petrinaut-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export {
colorSchema,
componentInstanceSchema,
createPetrinautAiWritableCallbacks,
createExperimentToolName,
differentialEquationSchema,
getLatestNetDefinitionToolName,
getNetCompilationErrorsToolName,
Expand Down
39 changes: 35 additions & 4 deletions libs/@hashintel/petrinaut/docs/ai-assistant.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# AI Assistant

Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant is available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds.
Petrinaut has an in-app AI assistant that can build a net from a natural-language description, review or revise an existing one, read TypeScript compilation diagnostics, run experiments, and consult its own user-guide pages to answer "how do I ..." questions. The host application controls whether the assistant and its tools are available -- it is enabled on [demo.petrinaut.org](https://demo.petrinaut.org) and in [HASH](https://hash.ai) and may or may not be enabled in other Petrinaut embeds.

## Opening the panel

Expand All @@ -10,7 +10,13 @@ There are two entry points:
2. **File → New → Build with Brunch**. When the host enables its experimental Brunch demo mode, shows net-management controls, and provides an assistant, this creates a fresh empty net, opens the assistant, and offers two chips before the first message: **Interview first** (elicit before inventing missing detail or drawing the net) or **Quick preview** (fill reasonable gaps, mark them as provisional, and wait for assent). **Start blank** creates the same empty net without opening the assistant or showing the first-run prompt. Without that host flag, **New** retains its ordinary direct blank-net behavior.
3. **First-run prompt**. When you load Petrinaut against an empty net, a centred prompt appears. Type a description and its trailing action becomes **Send**; select it to open the panel with your message already in flight. When the host provides Voice mode, the empty prompt instead shows a waveform action titled **Start voice mode**. It opens the same assistant without creating an empty text message. Dismiss the prompt with the **X**, by clicking outside it, or by pressing **Escape**; it is hidden for the rest of the session once dismissed.

The assistant panel only renders in **Edit** mode. Switching to **Simulate** mode hides it; switch back to **Edit** to continue the conversation. The panel resizes by dragging its left edge. Text and voice share the **AI** transcript. Some hosts add a second tab, such as **Workpiece**, for a saved document. Select a tab to switch views, or use the left/right arrow keys while a tab is focused. Switching does not end a response, clear your draft or interrupt Voice; the composer and active controls remain available.
The assistant panel stays open when you switch to **Simulate** or **Notebook**
mode, so you can inspect an experiment while its chat result arrives. The panel
resizes by dragging its left edge. Text and voice share the **AI** transcript.
Some hosts add a second tab, such as **Workpiece**, for a saved document.
Select a tab to switch views, or use the left/right arrow keys while a tab is
focused. Switching does not end a response, clear your draft or interrupt Voice;
the composer and active controls remain available.

## The conversation

Expand Down Expand Up @@ -168,6 +174,30 @@ For example, the Petrinaut website's worked-model route displays its template ti

After applying changes, the assistant may automatically check TypeScript compile diagnostics (you'll see a **Checked net compilation errors** card) and fix problems on its own before continuing.

## Experiments from chat

When your host enables experiment tools, ask the assistant to run a saved
[scenario](scenarios.md) and measure one or more saved metrics.
For example: "Run 100 simulations of this scenario and show the completed
orders metric." The assistant can also search numeric scenario parameter
ranges to minimize or maximize a metric.

The experiment appears in a compact card with its status, run count, and
results. Simulation cards use blue; optimization cards use purple and glow
while running. Select **View
experiment** to inspect metric distributions in the Experiments drawer. The
heatmap shows how values spread across runs; click a time step to see its
histogram. Select **Cancel**
to stop its work. The assistant receives the
result when the requested work finishes; an optimization includes the final
runs at its best parameter values.

While an AI experiment runs, its compute-changing controls are locked. You
can inspect its charts or cancel it. After completion you can explore its
parameters again; the result already recorded in chat stays unchanged.
Experiments run in your current browser session, so keep the page open until
they finish. See [Experiments](experiments.md#experiments-created-by-the-assistant).

## Read-only behaviour

Whether the assistant can change the net depends on the editor state:
Expand All @@ -182,8 +212,9 @@ The composer stays open in all of these cases, so you can still ask questions, r
The assistant can request a fresh TypeScript check of the current net and
use the returned errors to revise its code. An unchanged set of errors still
counts as a completed check. If checking fails, the assistant receives an
error. The bottom **Diagnostics** tab continues to show diagnostics for the
code you are editing.
error. Experiment creation also checks its selected
scenario and metrics before running. The bottom **Diagnostics** tab continues
to show diagnostics for the code you are editing.

## Host configuration

Expand Down
20 changes: 12 additions & 8 deletions libs/@hashintel/petrinaut/docs/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,21 @@ Experiments live under the **Simulate** [global mode](drawing-a-net.md#global-mo
3. Fill in the configuration (see below).
4. Click **Run** -- **Create sweep** when a value is swept, **Optimize** when the in-browser optimizer will search it. The button reads **Starting** (or **Creating**) while the experiment starts.

### Experiments started by an integration
### Experiments created by the assistant

An application embedding Petrinaut can start an experiment with a saved
scenario and metrics, including a search over numeric parameter ranges.
These experiments appear in the Experiments list.
If your host enables experiment tools, the [AI assistant](ai-assistant.md#experiments-from-chat)
can run a saved scenario with saved metrics and fixed parameter values. It
can also optimize numeric parameter ranges. These requests create ordinary
experiments in this list and show a progress card in chat.

While the assistant's request runs, parameter changes and removal are locked.
You can inspect the charts and cancel the experiment. When it finishes, the
assistant receives its results and the controls become available again.
Later exploration does not change the result already recorded in chat.

While the request runs, parameter changes and removal are locked. You can
inspect the charts. Controls become available when the request finishes.
Later exploration leaves the result returned to the application unchanged.
An optimization finishes after its search and the final runs at its best
parameter values. Keep the browser page open until the request finishes.
parameter values. Keep the browser page open until the request finishes;
experiments are not restored after a reload.

### Configuration

Expand Down
130 changes: 130 additions & 0 deletions libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen } from "@testing-library/react";
import { use, useEffect } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";

import {
EditorContext,
type EditorGlobalMode,
} from "../../../react/state/editor-context";
import { UserSettingsContext } from "../../../react/state/user-settings-context";
import { EditorView } from "./editor-view";

import type { PetrinautAiAssistant } from "../../petrinaut";
import type { UIMessageChunk } from "ai";

const lifecycle = vi.hoisted(() => ({
mount: vi.fn(),
cancelPendingRequest: vi.fn(),
}));

vi.mock("../../../react", () => ({
usePetrinautCommands: () => ({ applyAutoLayout: vi.fn() }),
}));
vi.mock("../../../react/state/use-selection-cleanup", () => ({
useSelectionCleanup: () => {},
}));
vi.mock("../../../react/state/use-is-read-only", () => ({
useIsReadOnly: () => false,
}));
vi.mock("./panels/ai-assistant-panel", () => ({
AiAssistantPanel: () => {
useEffect(() => {
lifecycle.mount();
return lifecycle.cancelPendingRequest;
}, []);
return <section aria-label="AI assistant">Pending experiment</section>;
},
}));
vi.mock("./panels/SimulateView/simulate-view", () => ({
SimulateView: () => (
<section aria-label="Experiments">Experiment results</section>
),
}));
vi.mock("../Notebook/notebook-view", () => ({
NotebookView: () => <section aria-label="Notebook" />,
}));
vi.mock("../SDCPN/sdcpn-view", () => ({ SDCPNView: () => null }));
vi.mock("./panels/LeftSideBar/panel", () => ({ LeftSideBar: () => null }));
vi.mock("./panels/PropertiesPanel/panel", () => ({
PropertiesPanel: () => null,
}));
vi.mock("./panels/BottomPanel/panel", () => ({ BottomPanel: () => null }));
vi.mock("./components/BottomBar/bottom-bar", () => ({ BottomBar: () => null }));
vi.mock("./components/TopBar/top-bar", () => ({ TopBar: () => null }));
vi.mock("./components/ai-cta-modal", () => ({ AiCtaModal: () => null }));
vi.mock("./components/import-error-dialog", () => ({
ImportErrorDialog: () => null,
}));
vi.mock("../../components/walkthrough/walkthrough-dialog", () => ({
WalkthroughDialog: () => null,
}));
vi.mock("./simulation-creation-drawer", () => ({
SimulationCreationDrawer: () => null,
}));
vi.mock("./use-editor-commands", () => ({ EditorCommands: () => null }));

const aiAssistant: PetrinautAiAssistant = {
transport: {
reconnectToStream: () => Promise.resolve(null),
sendMessages: () =>
Promise.resolve(
new ReadableStream<UIMessageChunk>({
start(controller) {
controller.close();
},
}),
),
},
};

const EditorAtMode = ({ mode }: { mode: EditorGlobalMode }) => {
const editor = use(EditorContext);
const settings = use(UserSettingsContext);
return (
<UserSettingsContext.Provider
value={{ ...settings, enableNotebookView: true }}
>
<EditorContext.Provider
value={{ ...editor, globalMode: mode, isAiAssistantOpen: true }}
>
<EditorView aiAssistant={aiAssistant} titleEditable />
</EditorContext.Provider>
</UserSettingsContext.Provider>
);
};

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

describe("EditorView assistant lifecycle", () => {
test("keeps the pending assistant mounted when opening experiment results and changing modes", () => {
const { rerender, unmount } = render(<EditorAtMode mode="edit" />);
const assistant = screen.getByRole("region", { name: "AI assistant" });
expect(lifecycle.mount).toHaveBeenCalledTimes(1);

rerender(<EditorAtMode mode="simulate" />);
expect(screen.getByRole("region", { name: "Experiments" })).toBeTruthy();
expect(screen.getByRole("region", { name: "AI assistant" })).toBe(
assistant,
);

rerender(<EditorAtMode mode="notebook" />);
expect(screen.getByRole("region", { name: "Notebook" })).toBeTruthy();
expect(screen.getByRole("region", { name: "AI assistant" })).toBe(
assistant,
);

rerender(<EditorAtMode mode="edit" />);
expect(screen.getByRole("region", { name: "AI assistant" })).toBe(
assistant,
);
expect(lifecycle.mount).toHaveBeenCalledTimes(1);
expect(lifecycle.cancelPendingRequest).not.toHaveBeenCalled();

unmount();
expect(lifecycle.cancelPendingRequest).toHaveBeenCalledTimes(1);
});
});
35 changes: 18 additions & 17 deletions libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const formatRelativeTime = (isoTimestamp: string): string => {
// overflow, scrollIntoView can still scroll it programmatically — pushing the
// TopBar out of view.
const rowContainerStyle = css({
position: "relative",
flex: "[1]",
minHeight: "[0]",
userSelect: "none",
Expand Down Expand Up @@ -573,25 +574,25 @@ export const EditorView = ({
onCursorModeChange={setCursorMode}
hasAiAssistant={aiAssistant !== undefined}
/>

{aiAssistant && (
<AiAssistantPanel
/** Reset state (e.g. initial messages) when the active net changes */
key={petriNetId ?? "no-net"}
aiAssistant={aiAssistant}
initialMessage={pendingAiAssistantMessage}
initialInteractionMode={pendingAiInteractionMode}
offerStartPosture={offerStartPosture}
onInitialMessageConsumed={() =>
setPendingAiAssistantMessage(null)
}
onInitialInteractionModeConsumed={() =>
setPendingAiInteractionMode(null)
}
/>
)}
</Box>
)}

{aiAssistant && (
<AiAssistantPanel
/** Reset state (e.g. initial messages) when the active net changes */
key={petriNetId ?? "no-net"}
aiAssistant={aiAssistant}
initialMessage={pendingAiAssistantMessage}
initialInteractionMode={pendingAiInteractionMode}
offerStartPosture={offerStartPosture}
onInitialMessageConsumed={() =>
setPendingAiAssistantMessage(null)
}
onInitialInteractionModeConsumed={() =>
setPendingAiInteractionMode(null)
}
/>
)}
</Stack>
</VoiceSessionProvider>

Expand Down
Loading
Loading