Skip to content

Commit d7ba77c

Browse files
committed
feat(studio): give an agent eyes with studio_frame
Renders the composition to a PNG at a given time and returns the URL. This is what turns the tool set from a remote control into a loop: author a change, capture the instant it affects, look, adjust. No agent can judge motion from source, because "what does this look like at 2.4 seconds" is not a question a file answers. Reuses Studio's existing capture endpoint via `buildFrameCaptureUrl` rather than inventing a second one. Two things this does not fake: It reports the time the playhead LANDED on, not the time requested. The player clamps, so those differ at the ends, and attaching the wrong time to a frame is how an agent draws a confident wrong conclusion about motion. It waits before capturing, by default 150ms. The frame is rendered from the file on disk, and the render cache is cleared by a file watcher with a 40ms write-stability threshold, so a capture that beats the watcher renders the PRE-edit composition. That exact staleness was a real bug here once. An agent reading a stale frame as "my edit failed" would thrash, so the wait is on by default, `settleMs` makes it tunable, and the tool description names the failure rather than leaving it to be rediscovered. It probes with HEAD before returning, so a URL that 404s comes back as a failure with a hint instead of as a link the agent cannot render.
1 parent 68a32e9 commit d7ba77c

5 files changed

Lines changed: 327 additions & 5 deletions

File tree

packages/studio/src/webmcp/StudioAgentTools.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,29 @@ export function StudioAgentTools() {
6363
isPlaying: player.isPlaying,
6464
};
6565
},
66+
getProjectId: () => projectId,
67+
getCompositionPath: () => activeCompPath,
68+
// HEAD, not GET: the tool only needs to know the frame renders. Pulling
69+
// the PNG here would download it once for nothing, since the agent
70+
// fetches the URL itself.
71+
probeFrame: async (url) => {
72+
try {
73+
const response = await fetch(url, { method: "HEAD" });
74+
return { ok: response.ok, status: response.status };
75+
} catch {
76+
return { ok: false, status: 0 };
77+
}
78+
},
79+
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
6680
}),
67-
[getSnapshot, previewIframeRef, buildDomSelectionFromTarget, applyDomSelection],
81+
[
82+
getSnapshot,
83+
previewIframeRef,
84+
buildDomSelectionFromTarget,
85+
applyDomSelection,
86+
projectId,
87+
activeCompPath,
88+
],
6889
);
6990

7091
useStudioAgentTools(deps);
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// @vitest-environment jsdom
2+
import { describe, expect, it, vi } from "vitest";
3+
import { studioFrame, type FrameToolDeps, type StudioFrameResult } from "./frameTools";
4+
import type { ToolFailure, ToolResult } from "../toolResult";
5+
6+
function frameDeps(overrides: Partial<FrameToolDeps> = {}): FrameToolDeps {
7+
return {
8+
getProjectId: () => "demo",
9+
getCompositionPath: () => "index.html",
10+
readPlayhead: () => ({ currentTime: 2.4, duration: 10, isPlaying: false }),
11+
requestSeek: () => undefined,
12+
probeFrame: async () => ({ ok: true, status: 200 }),
13+
wait: async () => undefined,
14+
...overrides,
15+
};
16+
}
17+
18+
function expectOk<T>(result: ToolResult<T>): { ok: true } & T {
19+
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
20+
return result;
21+
}
22+
23+
function expectFailure(result: ToolResult<unknown>): ToolFailure {
24+
if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`);
25+
return result;
26+
}
27+
28+
describe("studioFrame", () => {
29+
it("returns a URL for the composition at the playhead", async () => {
30+
const result = await studioFrame(frameDeps());
31+
32+
const ok = expectOk<StudioFrameResult>(result);
33+
expect(ok.time).toBe(2.4);
34+
expect(ok.compositionPath).toBe("index.html");
35+
expect(ok.url).toContain("/thumbnail/");
36+
expect(ok.url).toContain("t=2.400");
37+
expect(ok.url).toContain("format=png");
38+
});
39+
40+
it("seeks first when given a time", async () => {
41+
const requestSeek = vi.fn();
42+
43+
await studioFrame(frameDeps({ requestSeek }), { time: 5 });
44+
45+
expect(requestSeek).toHaveBeenCalledWith(5);
46+
});
47+
48+
it("captures where the playhead LANDED, not what was asked for", async () => {
49+
// The player clamps. Reporting the request would attach the wrong time to
50+
// the frame, and an agent judging motion would draw the wrong conclusion.
51+
const result = await studioFrame(
52+
frameDeps({ readPlayhead: () => ({ currentTime: 10, duration: 10, isPlaying: false }) }),
53+
{ time: 999 },
54+
);
55+
56+
const ok = expectOk<StudioFrameResult>(result);
57+
expect(ok.time).toBe(10);
58+
expect(ok.url).toContain("t=10.000");
59+
});
60+
61+
it("waits before capturing, so a just-made edit is in the frame", async () => {
62+
// The render cache is cleared by a file watcher with a write-stability
63+
// threshold. Capturing faster than that renders the PRE-edit composition.
64+
const wait = vi.fn(async () => undefined);
65+
const order: string[] = [];
66+
67+
await studioFrame(
68+
frameDeps({
69+
wait: async (ms) => {
70+
order.push(`wait:${ms}`);
71+
await wait();
72+
},
73+
probeFrame: async () => {
74+
order.push("probe");
75+
return { ok: true, status: 200 };
76+
},
77+
}),
78+
);
79+
80+
expect(order).toEqual(["wait:150", "probe"]);
81+
});
82+
83+
it("honours a caller-supplied settle time and reports it", async () => {
84+
const result = await studioFrame(frameDeps(), { settleMs: 800 });
85+
86+
expect(expectOk<StudioFrameResult>(result).settledMs).toBe(800);
87+
});
88+
89+
it("clamps an absurd settle time rather than hanging", async () => {
90+
const result = await studioFrame(frameDeps(), { settleMs: 10 * 60 * 1000 });
91+
92+
expect(expectOk<StudioFrameResult>(result).settledMs).toBe(5000);
93+
});
94+
95+
it("falls back to the default for a nonsense settle time", async () => {
96+
for (const settleMs of [-1, Number.NaN]) {
97+
const result = await studioFrame(frameDeps(), { settleMs });
98+
expect(expectOk<StudioFrameResult>(result).settledMs).toBe(150);
99+
}
100+
});
101+
102+
it("skips the wait entirely when asked for zero", async () => {
103+
const wait = vi.fn(async () => undefined);
104+
105+
await studioFrame(frameDeps({ wait }), { settleMs: 0 });
106+
107+
expect(wait).not.toHaveBeenCalled();
108+
});
109+
110+
it("reports a renderer failure instead of handing back a dead URL", async () => {
111+
const result = expectFailure(
112+
await studioFrame(frameDeps({ probeFrame: async () => ({ ok: false, status: 500 }) })),
113+
);
114+
115+
expect(result.kind).toBe("failed");
116+
expect(result.reason).toContain("500");
117+
expect(result.hint).toBeDefined();
118+
});
119+
120+
it("fails when no project is open, before touching the renderer", async () => {
121+
const probeFrame = vi.fn();
122+
123+
const result = expectFailure(
124+
await studioFrame(frameDeps({ getProjectId: () => null, probeFrame })),
125+
);
126+
127+
expect(result.kind).toBe("blocked");
128+
expect(probeFrame).not.toHaveBeenCalled();
129+
});
130+
131+
it("rejects a negative or non-finite time without seeking", async () => {
132+
const requestSeek = vi.fn();
133+
134+
for (const time of [-1, Number.NaN, Number.POSITIVE_INFINITY]) {
135+
const result = expectFailure(await studioFrame(frameDeps({ requestSeek }), { time }));
136+
expect(result.kind).toBe("invalid");
137+
}
138+
expect(requestSeek).not.toHaveBeenCalled();
139+
});
140+
141+
it("captures the master composition when no path is active", async () => {
142+
const result = await studioFrame(frameDeps({ getCompositionPath: () => null }));
143+
144+
expect(expectOk<StudioFrameResult>(result).compositionPath).toBe("index.html");
145+
});
146+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/**
2+
* `studio_frame`: the eyes.
3+
*
4+
* Without this the tool set is a remote control. With it an agent can author a
5+
* change, look at the instant it affects, judge it, and adjust. That loop is the
6+
* one thing source alone cannot support, because "what does this look like at
7+
* 2.4 seconds" is not a question a file can answer.
8+
*
9+
* Reuses Studio's existing capture endpoint (`utils/frameCapture`) rather than
10+
* inventing a second one. The server renders the composition with Puppeteer, so
11+
* the frame reflects the file on disk, not the live preview DOM.
12+
*/
13+
14+
import { buildFrameCaptureUrl } from "../../utils/frameCapture";
15+
import { toolFailure, toolOk, type ToolResult } from "../toolResult";
16+
17+
export interface FrameToolDeps {
18+
getProjectId: () => string | null;
19+
getCompositionPath: () => string | null;
20+
readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean };
21+
requestSeek: (time: number) => void;
22+
/** Confirms the URL renders. Injected so tests need no network. */
23+
probeFrame: (url: string) => Promise<{ ok: boolean; status: number }>;
24+
wait: (ms: number) => Promise<void>;
25+
}
26+
27+
export interface StudioFrameResult {
28+
/** Fetch this to see the frame. A PNG of the composition at `time`. */
29+
url: string;
30+
time: number;
31+
compositionPath: string;
32+
/** How long the tool waited for a pending write to settle before capturing. */
33+
settledMs: number;
34+
}
35+
36+
export interface StudioFrameInput {
37+
/** Seconds. Omit to capture wherever the playhead already is. */
38+
time?: number;
39+
/**
40+
* Milliseconds to wait before capturing, so a just-written edit is visible.
41+
* See the staleness note in the description.
42+
*/
43+
settleMs?: number;
44+
}
45+
46+
/**
47+
* Long enough to cover the project watcher's 40ms write-stability threshold
48+
* plus filesystem latency, short enough not to be felt. This is the mitigation
49+
* for a real, previously-fixed bug: the preview signature is invalidated by a
50+
* file watcher, and a capture that beats the watcher renders the PRE-edit
51+
* composition. An agent reading that as "my edit failed" would thrash.
52+
*/
53+
const DEFAULT_SETTLE_MS = 150;
54+
const MAX_SETTLE_MS = 5_000;
55+
56+
export async function studioFrame(
57+
deps: FrameToolDeps,
58+
input: StudioFrameInput = {},
59+
): Promise<ToolResult<StudioFrameResult>> {
60+
const projectId = deps.getProjectId();
61+
if (!projectId) {
62+
return toolFailure("blocked", "no project is open");
63+
}
64+
65+
if (input.time !== undefined) {
66+
if (typeof input.time !== "number" || !Number.isFinite(input.time) || input.time < 0) {
67+
return toolFailure("invalid", "time must be a non-negative, finite number of seconds");
68+
}
69+
deps.requestSeek(input.time);
70+
}
71+
72+
const settledMs = clampSettle(input.settleMs);
73+
if (settledMs > 0) await deps.wait(settledMs);
74+
75+
// Capture whatever the playhead now reads, rather than what was requested:
76+
// the player clamps, so those can differ and the frame belongs to the former.
77+
const { currentTime } = deps.readPlayhead();
78+
const compositionPath = deps.getCompositionPath();
79+
const url = buildFrameCaptureUrl({ projectId, compositionPath, currentTime });
80+
81+
const probe = await deps.probeFrame(url);
82+
if (!probe.ok) {
83+
return toolFailure(
84+
"failed",
85+
`the renderer returned ${probe.status} for this frame`,
86+
"The composition may not build. Try `hyperframes check`.",
87+
);
88+
}
89+
90+
return toolOk<StudioFrameResult>({
91+
url,
92+
time: currentTime,
93+
compositionPath: compositionPath ?? "index.html",
94+
settledMs,
95+
});
96+
}
97+
98+
function clampSettle(requested: number | undefined): number {
99+
if (requested === undefined) return DEFAULT_SETTLE_MS;
100+
if (typeof requested !== "number" || !Number.isFinite(requested) || requested < 0) {
101+
return DEFAULT_SETTLE_MS;
102+
}
103+
return Math.min(requested, MAX_SETTLE_MS);
104+
}
105+
106+
export const STUDIO_FRAME_INPUT_SCHEMA = {
107+
type: "object",
108+
properties: {
109+
time: {
110+
type: "number",
111+
minimum: 0,
112+
description: "Seconds. Omit to capture wherever the playhead already is.",
113+
},
114+
settleMs: {
115+
type: "integer",
116+
minimum: 0,
117+
maximum: MAX_SETTLE_MS,
118+
description: `Wait this long before capturing so a just-made edit is included. Default ${DEFAULT_SETTLE_MS}.`,
119+
},
120+
},
121+
additionalProperties: false,
122+
} as const;
123+
124+
export const STUDIO_FRAME_DESCRIPTION = [
125+
"Render the composition to a PNG at a given time and return its URL, so you can",
126+
"SEE the result instead of inferring it from source. Use this to judge a change:",
127+
"edit, capture the instant it affects, look, adjust.",
128+
"The frame is rendered from the file on disk, not the live preview.",
129+
"A capture taken immediately after an edit can therefore predate that edit, because",
130+
"the render cache is cleared by a file watcher. The tool waits briefly to cover that;",
131+
"raise `settleMs` if a frame still looks stale, rather than concluding the edit failed.",
132+
"Returns `ok: true` with `url` and the `time` actually captured, or `ok: false`.",
133+
].join(" ");

packages/studio/src/webmcp/useStudioAgentTools.test.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDe
3636
applySelection: () => undefined,
3737
requestSeek: () => undefined,
3838
readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
39+
getProjectId: () => "demo",
40+
getCompositionPath: () => "index.html",
41+
probeFrame: async () => ({ ok: true, status: 200 }),
42+
wait: async () => undefined,
3943
...overrides,
4044
};
4145
}
@@ -99,6 +103,7 @@ describe("useStudioAgentTools", () => {
99103
"studio_look",
100104
"studio_select",
101105
"studio_seek",
106+
"studio_frame",
102107
]);
103108
});
104109

@@ -112,14 +117,14 @@ describe("useStudioAgentTools", () => {
112117
await act(async () => {
113118
harness = mountTools(deps({ getSnapshot: () => snapshot() }));
114119
});
115-
expect(registerTool).toHaveBeenCalledTimes(3);
120+
expect(registerTool).toHaveBeenCalledTimes(4);
116121

117122
await act(async () => {
118123
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) }));
119124
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) }));
120125
});
121126

122-
expect(registerTool).toHaveBeenCalledTimes(3);
127+
expect(registerTool).toHaveBeenCalledTimes(4);
123128
});
124129

125130
it("executes against the LATEST deps, not the ones present at registration", async () => {
@@ -192,7 +197,7 @@ describe("useStudioAgentTools", () => {
192197
mountTools(deps({ getSnapshot: () => snapshot() }));
193198
});
194199

195-
expect(registerTool).toHaveBeenCalledTimes(3);
200+
expect(registerTool).toHaveBeenCalledTimes(4);
196201
});
197202

198203
it("reports a tool that throws as an internal failure instead of rejecting", async () => {

packages/studio/src/webmcp/useStudioAgentTools.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,18 @@ import {
2424
type StudioSeekResult,
2525
type StudioSelectResult,
2626
} from "./tools/selectionTools";
27+
import {
28+
studioFrame,
29+
STUDIO_FRAME_DESCRIPTION,
30+
STUDIO_FRAME_INPUT_SCHEMA,
31+
type FrameToolDeps,
32+
type StudioFrameInput,
33+
type StudioFrameResult,
34+
} from "./tools/frameTools";
2735

2836
const log = makeStudioDebugLogger("webmcp");
2937

30-
export interface StudioAgentToolsDeps extends SelectionToolDeps {
38+
export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps {
3139
/** Read Studio's current state. Called per tool invocation, never cached. */
3240
getSnapshot: () => StudioLookSnapshot;
3341
}
@@ -79,6 +87,15 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
7987
studioSeek(depsRef.current, readNumberInput(input, "time")),
8088
),
8189
},
90+
{
91+
name: "studio_frame",
92+
title: "See the composition",
93+
description: STUDIO_FRAME_DESCRIPTION,
94+
inputSchema: STUDIO_FRAME_INPUT_SCHEMA,
95+
annotations: { readOnlyHint: true, untrustedContentHint: true },
96+
execute: (input): Promise<ToolResult<StudioFrameResult>> =>
97+
runToolBody("studio_frame", () => studioFrame(depsRef.current, input as StudioFrameInput)),
98+
},
8299
];
83100
}
84101

0 commit comments

Comments
 (0)