Skip to content

Commit 003b99d

Browse files
committed
feat(timeline): let an agent replace a whole timeline document [T18]
An agent could edit a timeline only through `edit_timeline`'s op list, so authoring a sequence from scratch, or applying a document it had built elsewhere, meant expressing the whole thing as incremental ops. `set_timeline_document` replaces the document in one call, in an order chosen so a refused call leaves nothing behind: ownership, then the `expected_updated_at` precheck, then validation, then the snapshot, then the CAS write, then a re-validation of what was actually stored. A conflict or a validation error returns before the snapshot — a refused call that leaves a version row behind is still a write. The write itself is a single attempt with no retry loop: `edit_timeline` retries because its ops re-apply against a newer document, but a whole-document replace does not compose, so retrying would clobber exactly the change CAS just caught. The return carries the post-write validation rather than the pre-write one, so a caller sees what it created instead of what it intended, plus the version id to undo it. `duration_ms` is restamped from the clips, because a replaced document that keeps the old sequence duration makes `preview_timeline_frame` truncate its sampling window. Resolves F18. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdzG3a3jN3Dzx6QV8xh4Bo
1 parent 3564ef7 commit 003b99d

8 files changed

Lines changed: 530 additions & 7 deletions

File tree

packages/agents/src/capabilities/timelines.specs.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,72 @@ export const editTimelineSpec: CapabilitySpec = {
353353
}
354354
};
355355

356+
export const SET_TIMELINE_DOCUMENT_SCHEMA: JsonSchema = {
357+
type: "object",
358+
properties: {
359+
timeline_id: {
360+
type: "string",
361+
description: "Timeline sequence id (from list_timelines)."
362+
},
363+
document: {
364+
type: "object",
365+
description:
366+
"The whole document to store: {tracks, clips, markers, transcript?, " +
367+
"scriptEnabled?}. It replaces the stored one field for field, so " +
368+
"anything you leave out is dropped — read the current document with " +
369+
"get_timeline and send it back changed, rather than sending only the " +
370+
"part you edited. `markers` may be omitted and defaults to an empty " +
371+
"list."
372+
},
373+
fps: {
374+
type: "number",
375+
description:
376+
"Frame rate to store with the sequence. Omit to keep the current one."
377+
},
378+
width: {
379+
type: "number",
380+
description: "Render width. Omit to keep the current one."
381+
},
382+
height: {
383+
type: "number",
384+
description: "Render height. Omit to keep the current one."
385+
},
386+
expected_updated_at: {
387+
type: "string",
388+
description:
389+
"The `updated_at` the document was read at. When it no longer " +
390+
"matches the stored row the write is refused as a conflict instead " +
391+
"of overwriting whoever changed it in between."
392+
},
393+
snapshot_name: {
394+
type: "string",
395+
description:
396+
"Label for the snapshot taken before the write, e.g. 'before the " +
397+
"title pass'."
398+
}
399+
},
400+
required: ["timeline_id", "document"]
401+
};
402+
403+
export const setTimelineDocumentSpec: CapabilitySpec = {
404+
name: "set_timeline_document",
405+
description:
406+
"Write a whole timeline document at once — every track, clip, marker and " +
407+
"animation in one call, instead of a script of edit_timeline ops. Reach " +
408+
"for it when you are authoring a cut from scratch or restructuring one " +
409+
"wholesale; edit_timeline stays the better tool for a few targeted " +
410+
"changes to a cut that already exists. The document is validated before " +
411+
"anything is written: errors refuse the write and come back as issues, " +
412+
"so a document that would not render never reaches the sequence. The " +
413+
"state it replaces is snapshotted as a manual version first, so the " +
414+
"write is undoable with restore_timeline_version, and the validation of " +
415+
"what actually landed is returned with the result.",
416+
inputSchema: SET_TIMELINE_DOCUMENT_SCHEMA,
417+
category: "write",
418+
userMessage: (params) =>
419+
`Writing the document of timeline ${String(params["timeline_id"])}`
420+
};
421+
356422
export const validateTimelineSpec: CapabilitySpec = {
357423
name: "validate_timeline",
358424
description:
@@ -473,6 +539,7 @@ export const timelinesSpecs: readonly CapabilitySpec[] = [
473539
deleteTimelineVersionSpec,
474540
editTimelineSpec,
475541
validateTimelineSpec,
542+
setTimelineDocumentSpec,
476543
previewTimelineFrameSpec,
477544
deleteTimelineSpec
478545
];

packages/agents/src/capabilities/timelines.ts

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
deleteTimelineVersionSpec,
4949
editTimelineSpec,
5050
validateTimelineSpec,
51+
setTimelineDocumentSpec,
5152
previewTimelineFrameSpec,
5253
DEFAULT_PREVIEW_COUNT,
5354
MAX_PREVIEW_TIMES,
@@ -64,6 +65,7 @@ import {
6465
DELETE_TIMELINE_VERSION_SCHEMA,
6566
EDIT_TIMELINE_SCHEMA,
6667
VALIDATE_TIMELINE_SCHEMA,
68+
SET_TIMELINE_DOCUMENT_SCHEMA,
6769
deleteTimelineSpec
6870
} from "./timelines.specs.js";
6971
import { isFiniteNumber, isRecord, isString } from "../utils/type-guards.js";
@@ -81,7 +83,8 @@ export {
8183
RESTORE_TIMELINE_VERSION_SCHEMA,
8284
DELETE_TIMELINE_VERSION_SCHEMA,
8385
EDIT_TIMELINE_SCHEMA,
84-
VALIDATE_TIMELINE_SCHEMA
86+
VALIDATE_TIMELINE_SCHEMA,
87+
SET_TIMELINE_DOCUMENT_SCHEMA
8588
} from "./timelines.specs.js";
8689
import { resolveProjectId } from "./project-scope.js";
8790

@@ -905,6 +908,192 @@ const validateTimeline: CapabilityExport = {
905908
}
906909
};
907910

911+
912+
// ---------------------------------------------------------------------------
913+
// set_timeline_document
914+
// ---------------------------------------------------------------------------
915+
916+
/**
917+
* A document the caller sent, ready to validate.
918+
*
919+
* `markers` is the one field an agent authoring a cut has no reason to think
920+
* about, and the Zod schema requires it — so an omitted one becomes an empty
921+
* list rather than a `schema_invalid` refusal about a field nobody meant to
922+
* leave out. Everything else is stored as given: the whole point of this
923+
* capability is that what the caller sends is what the sequence becomes.
924+
*/
925+
function documentToStore(raw: Record<string, unknown>): Record<string, unknown> {
926+
return raw["markers"] === undefined ? { ...raw, markers: [] } : raw;
927+
}
928+
929+
/** The end of the last clip — what the sequence's stored duration means. */
930+
function documentDurationMs(document: Record<string, unknown>): number {
931+
const clips = document["clips"];
932+
if (!Array.isArray(clips)) return 0;
933+
return clips.reduce((end: number, clip: unknown) => {
934+
if (!isRecord(clip)) return end;
935+
const start = Number(clip["startMs"]) || 0;
936+
const length = Number(clip["durationMs"]) || 0;
937+
return Math.max(end, start + length);
938+
}, 0);
939+
}
940+
941+
/**
942+
* Replace a sequence's whole document in one call.
943+
*
944+
* `edit_timeline` is a script of ops against what is already stored, which is
945+
* the wrong shape for authoring a cut from nothing: every clip costs an op,
946+
* and the ops only reach the fields the bridge exposes. This takes the
947+
* document itself.
948+
*
949+
* The order is validate → snapshot → CAS → validate again, and each step
950+
* exists for a failure the one before it cannot catch:
951+
*
952+
* 1. **Validate first.** A document that would not render must not reach the
953+
* database — a refusal that still wrote is worse than no capability at all.
954+
* Errors return the issues and nothing else happens, snapshot included: a
955+
* refused call that left a version row behind is still a write.
956+
* 2. **Snapshot.** The state being replaced becomes a manual version, so the
957+
* write is undoable with `restore_timeline_version` the way a restore is.
958+
* 3. **CAS.** The write lands only while the row still reads as it did, so a
959+
* concurrent edit is reported instead of overwritten. There is no retry
960+
* loop here, unlike `edit_timeline`: ops re-apply against a newer document
961+
* and compose, a whole-document replace does not — retrying it would clobber
962+
* exactly the change CAS caught.
963+
* 4. **Validate again.** The caller sees what it created rather than what it
964+
* intended. The two validations differ: the second runs against the stored
965+
* fps/width/height and the document as the row now holds it.
966+
*/
967+
const setTimelineDocument: CapabilityExport = {
968+
spec: setTimelineDocumentSpec,
969+
impl: async (run, params) => {
970+
const sequence = await loadTimeline(run, params["timeline_id"]);
971+
if (isError(sequence)) return sequence;
972+
973+
const raw = params["document"];
974+
if (!isRecord(raw)) {
975+
return {
976+
error:
977+
"document is required and must be an object ({tracks, clips, markers}). Read the current one with get_timeline."
978+
};
979+
}
980+
981+
const expected = params["expected_updated_at"];
982+
if (expected !== undefined && expected !== null) {
983+
if (!isString(expected)) {
984+
return { error: "expected_updated_at must be a string timestamp." };
985+
}
986+
if (expected !== sequence.updated_at) {
987+
return {
988+
error: `Timeline ${sequence.id} was modified since it was read at ${expected} (it now reads ${sequence.updated_at}); nothing was written. Read it again with get_timeline and re-apply your changes.`,
989+
written: false,
990+
conflict: true,
991+
timeline_id: sequence.id
992+
};
993+
}
994+
}
995+
996+
const fps = sequenceSetting(params["fps"], sequence.fps, "fps");
997+
if (isError(fps)) return fps;
998+
const width = sequenceSetting(params["width"], sequence.width, "width");
999+
if (isError(width)) return width;
1000+
const height = sequenceSetting(params["height"], sequence.height, "height");
1001+
if (isError(height)) return height;
1002+
1003+
const document = documentToStore(raw);
1004+
const { validateTimelineSequence } =
1005+
await import("@nodetool-ai/execution/timeline-debug");
1006+
const before: TimelineValidation = validateTimelineSequence(document, {
1007+
fps,
1008+
width,
1009+
height
1010+
});
1011+
if (!before.ok) {
1012+
return {
1013+
error: `The document has ${before.errors.length} error${
1014+
before.errors.length === 1 ? "" : "s"
1015+
} and was not written to timeline ${sequence.id}. Fix them and call again.`,
1016+
written: false,
1017+
timeline_id: sequence.id,
1018+
validation: before,
1019+
summary: validationSummary(before)
1020+
};
1021+
}
1022+
1023+
const { TimelineSequence, TimelineSequenceVersion } =
1024+
await import("@nodetool-ai/models");
1025+
const snapshotName =
1026+
isString(params["snapshot_name"]) && params["snapshot_name"]
1027+
? params["snapshot_name"]
1028+
: "Before set_timeline_document";
1029+
const undo = await TimelineSequenceVersion.snapshot(sequence, {
1030+
saveType: "manual",
1031+
name: snapshotName
1032+
});
1033+
1034+
const durationMs = documentDurationMs(document);
1035+
let saved: TimelineSequence | null;
1036+
try {
1037+
saved = await TimelineSequence.updateFieldsIfUnchanged(
1038+
sequence.id,
1039+
sequence.updated_at,
1040+
{
1041+
document: JSON.stringify(document),
1042+
fps,
1043+
width,
1044+
height,
1045+
duration_ms: durationMs
1046+
}
1047+
);
1048+
} catch (error) {
1049+
// The model rejects a document without tracks/clips/markers arrays. The
1050+
// validation above already covers that, so reaching here means the two
1051+
// disagree — report it rather than throwing out of the capability.
1052+
return {
1053+
error: `The document was refused by the store: ${
1054+
error instanceof Error ? error.message : String(error)
1055+
}`,
1056+
written: false,
1057+
timeline_id: sequence.id,
1058+
undo_version: undo.version
1059+
};
1060+
}
1061+
if (!saved) {
1062+
return {
1063+
error: `Timeline ${sequence.id} was modified concurrently; nothing was written. Read it again with get_timeline and re-apply your changes.`,
1064+
written: false,
1065+
conflict: true,
1066+
timeline_id: sequence.id,
1067+
undo_version: undo.version
1068+
};
1069+
}
1070+
1071+
const after: TimelineValidation = validateTimelineSequence(
1072+
saved.toDocument(),
1073+
{ fps: saved.fps, width: saved.width, height: saved.height }
1074+
);
1075+
return {
1076+
ok: true,
1077+
written: true,
1078+
timeline_id: saved.id,
1079+
updated_at: saved.updated_at,
1080+
undo_version: undo.version,
1081+
fps: saved.fps,
1082+
width: saved.width,
1083+
height: saved.height,
1084+
duration_ms: saved.duration_ms,
1085+
track_count: Array.isArray(document["tracks"])
1086+
? document["tracks"].length
1087+
: 0,
1088+
clip_count: Array.isArray(document["clips"])
1089+
? document["clips"].length
1090+
: 0,
1091+
validation: after,
1092+
summary: validationSummary(after)
1093+
};
1094+
}
1095+
};
1096+
9081097
/**
9091098
* The timecodes to render when the caller names none: evenly spaced across the
9101099
* sequence, avoiding both ends — the first and last frame of a cut are the two
@@ -1126,6 +1315,7 @@ export const TIMELINE_CAPABILITIES: readonly CapabilityExport[] = [
11261315
deleteTimelineVersion,
11271316
editTimeline,
11281317
validateTimeline,
1318+
setTimelineDocument,
11291319
previewTimelineFrame,
11301320
deleteTimeline
11311321
];
@@ -1146,6 +1336,7 @@ export {
11461336
deleteTimelineVersion,
11471337
editTimeline,
11481338
validateTimeline,
1339+
setTimelineDocument,
11491340
previewTimelineFrame,
11501341
deleteTimeline
11511342
};

packages/agents/src/codeact/nodetool-api.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,8 @@ export const NODETOOL_API_NAMESPACE_TOOLS: Record<string, readonly string[]> = {
124124
"delete_timeline_version",
125125
"validate_timeline",
126126
"preview_timeline_frame",
127-
"edit_timeline"
127+
"edit_timeline",
128+
"set_timeline_document"
128129
],
129130
sketches: [
130131
"list_sketches",
@@ -1159,7 +1160,18 @@ const nodetool = (() => {
11591160
__merge(opts, { document: target })
11601161
),
11611162
/** Apply document edits to a saved sequence, server-side. */
1162-
edit: (id, ops) => __need("edit_timeline")({ timeline_id: id, ops: ops })
1163+
edit: (id, ops) => __need("edit_timeline")({ timeline_id: id, ops: ops }),
1164+
/**
1165+
* Write a whole document at once. The document replaces the stored one,
1166+
* so send back what get() returned, changed. Validated before anything
1167+
* is written and snapshotted first, so a bad document is refused and a
1168+
* good one is undoable. Options: {fps, width, height,
1169+
* expected_updated_at, snapshot_name}.
1170+
*/
1171+
setDocument: (id, document, opts) =>
1172+
__need("set_timeline_document")(
1173+
__merge(opts, { timeline_id: id, document: document })
1174+
)
11631175
},
11641176
11651177
sketches: {
@@ -1598,7 +1610,10 @@ const NAMESPACE_DOCS: PromptEntry[] = [
15981610
chosen timecodes — read the picture back instead of guessing at it),
15991611
\`versions(id)\`,
16001612
\`getVersion(id, n)\`, \`snapshot(id, {name})\`, \`restore(id, n)\`,
1601-
\`deleteVersion(id, n)\`, and
1613+
\`deleteVersion(id, n)\`,
1614+
\`setDocument(id, document, {fps, width, height, expected_updated_at,
1615+
snapshot_name})\` — the whole document in one call, validated before it is
1616+
written and snapshotted first, for authoring a cut from scratch — and
16021617
\`edit(id, ops)\` — the cut itself, server-side: \`[{op: "add_track", type:
16031618
"audio"}, {op: "add_text_clip", text: "Hi"}, {op: "split_clip", target:
16041619
"shot", atMs: 3000}, {op: "animate_clip", target: "Hi", animations:

packages/agents/src/tools/tool-permissions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ export const TOOL_PERMISSION_CATEGORIES: Readonly<
268268
// Document edits: each rewrites a stored document under a CAS.
269269
edit_timeline: "write",
270270
edit_sketch: "write",
271+
// Replaces a timeline's whole document, after snapshotting what it replaces.
272+
set_timeline_document: "write",
271273
// Writes the glTF back over the asset it came from.
272274
edit_model3d: "write",
273275
create_model3d: "write",

packages/agents/tests/capabilities-registry.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ const CAPABILITY_CATEGORY_SNAPSHOT: Record<string, PermissionCategory> = {
239239
segment_image: "write",
240240
serpapi_search: "read",
241241
set_setting: "write",
242+
set_timeline_document: "write",
242243
set_workflow_access: "external",
243244
share_result: "read",
244245
start_background_job: "execute",

0 commit comments

Comments
 (0)