feat: add agent delete tool
This commit is contained in:
parent
7c19c72058
commit
1762661309
|
|
@ -63,6 +63,13 @@ const testTool: ToolDefinition = {
|
||||||
execute: async (args) => ({ echoed: args.input, flag: args.flag }),
|
execute: async (args) => ({ echoed: args.input, flag: args.flag }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stringArrayTool: ToolDefinition = {
|
||||||
|
name: "_test_string_array_tool",
|
||||||
|
description: "A test tool with string array params",
|
||||||
|
parameters: [{ key: "ids", type: "string[]", required: true }],
|
||||||
|
execute: async (args) => ({ ids: args.ids }),
|
||||||
|
};
|
||||||
|
|
||||||
/** Tool that intentionally throws during execution — used to prove the
|
/** Tool that intentionally throws during execution — used to prove the
|
||||||
* orchestrator's resolveToolCalls catch block works at runtime. */
|
* orchestrator's resolveToolCalls catch block works at runtime. */
|
||||||
const throwingTool: ToolDefinition = {
|
const throwingTool: ToolDefinition = {
|
||||||
|
|
@ -78,6 +85,7 @@ const throwingTool: ToolDefinition = {
|
||||||
|
|
||||||
// Register once (registry is a singleton — re-registering overwrites)
|
// Register once (registry is a singleton — re-registering overwrites)
|
||||||
toolRegistry.register("_test_tool", testTool);
|
toolRegistry.register("_test_tool", testTool);
|
||||||
|
toolRegistry.register("_test_string_array_tool", stringArrayTool);
|
||||||
toolRegistry.register("_test_throwing_tool", throwingTool);
|
toolRegistry.register("_test_throwing_tool", throwingTool);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -221,8 +229,8 @@ describe("orchestrator", () => {
|
||||||
const chat = useChatStore.getState();
|
const chat = useChatStore.getState();
|
||||||
const agent = useAgentStore.getState();
|
const agent = useAgentStore.getState();
|
||||||
|
|
||||||
// 8 iterations × (1 assistant + 1 tool_result) + 1 cap message = 17
|
// 20 iterations × (1 assistant + 1 tool_result) + 1 cap message = 41
|
||||||
expect(chat.messages).toHaveLength(17);
|
expect(chat.messages).toHaveLength(41);
|
||||||
|
|
||||||
// Last message is the cap message
|
// Last message is the cap message
|
||||||
const lastMsg = chat.messages[chat.messages.length - 1];
|
const lastMsg = chat.messages[chat.messages.length - 1];
|
||||||
|
|
@ -360,6 +368,36 @@ describe("orchestrator", () => {
|
||||||
expect(toolResult.content).toContain("must be a boolean");
|
expect(toolResult.content).toContain("must be a boolean");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates string array arguments", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
setMockFetch(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount === 1) {
|
||||||
|
return mockFetchResponse({
|
||||||
|
content: "Running tool",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
id: "tc_string_array",
|
||||||
|
name: "_test_string_array_tool",
|
||||||
|
args: { ids: ["a", 2] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return mockFetchResponse({ content: "Done" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await run(
|
||||||
|
[{ id: "1", role: "user", content: "Test", timestamp: Date.now() }],
|
||||||
|
MOCK_CONTEXT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolResult = useChatStore.getState().messages[1];
|
||||||
|
expect(toolResult.role).toBe("tool_result");
|
||||||
|
expect(toolResult.content).toContain("ids");
|
||||||
|
expect(toolResult.content).toContain("must be an array of strings");
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Error paths
|
// Error paths
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,39 @@ export const EditorContextAdapter = {
|
||||||
});
|
});
|
||||||
return { success: true, affectedElements };
|
return { success: true, affectedElements };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteTimelineElements({
|
||||||
|
elementIds,
|
||||||
|
}: {
|
||||||
|
elementIds: string[];
|
||||||
|
}): { success: boolean; deletedElements: string[] } | { error: string } {
|
||||||
|
const core = EditorCore.getInstance();
|
||||||
|
const activeScene = core.scenes.getActiveSceneOrNull();
|
||||||
|
if (!activeScene) {
|
||||||
|
return { error: "No active timeline" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTimelineContent(activeScene.tracks)) {
|
||||||
|
return { error: "No timeline content" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedIds = [...new Set(elementIds)];
|
||||||
|
const elements = findTimelineElementsByIds({
|
||||||
|
tracks: activeScene.tracks,
|
||||||
|
elementIds: requestedIds,
|
||||||
|
});
|
||||||
|
const foundIds = new Set(elements.map((element) => element.elementId));
|
||||||
|
const missingIds = requestedIds.filter(
|
||||||
|
(elementId) => !foundIds.has(elementId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingIds.length > 0) {
|
||||||
|
return { error: `Timeline elements not found: ${missingIds.join(", ")}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
core.timeline.deleteElements({ elements });
|
||||||
|
return { success: true, deletedElements: requestedIds };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function secondsToTicks(seconds: number): number {
|
function secondsToTicks(seconds: number): number {
|
||||||
|
|
@ -86,4 +119,26 @@ function hasTimelineContent(tracks: SceneTracks): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findTimelineElementsByIds({
|
||||||
|
tracks,
|
||||||
|
elementIds,
|
||||||
|
}: {
|
||||||
|
tracks: SceneTracks;
|
||||||
|
elementIds: string[];
|
||||||
|
}): Array<{ trackId: string; elementId: string }> {
|
||||||
|
const requestedIds = new Set(elementIds);
|
||||||
|
const result: Array<{ trackId: string; elementId: string }> = [];
|
||||||
|
const allTracks = [tracks.main, ...tracks.overlay, ...tracks.audio];
|
||||||
|
|
||||||
|
for (const track of allTracks) {
|
||||||
|
for (const element of track.elements) {
|
||||||
|
if (requestedIds.has(element.id)) {
|
||||||
|
result.push({ trackId: track.id, elementId: element.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export { buildSystemPrompt } from "@/agent/system-prompt";
|
export { buildSystemPrompt } from "@/agent/system-prompt";
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,11 @@ import "@/agent/tools/load-context.tool";
|
||||||
import "@/agent/tools/list-project-assets.tool";
|
import "@/agent/tools/list-project-assets.tool";
|
||||||
import "@/agent/tools/list-timeline.tool";
|
import "@/agent/tools/list-timeline.tool";
|
||||||
import "@/agent/tools/split.tool";
|
import "@/agent/tools/split.tool";
|
||||||
|
import "@/agent/tools/delete-timeline-elements.tool";
|
||||||
import { useChatStore } from "@/stores/chat-store";
|
import { useChatStore } from "@/stores/chat-store";
|
||||||
import { useAgentStore } from "@/stores/agent-store";
|
import { useAgentStore } from "@/stores/agent-store";
|
||||||
|
|
||||||
const MAX_ITERATIONS = 8;
|
const MAX_ITERATIONS = 20;
|
||||||
|
|
||||||
interface APIResponse {
|
interface APIResponse {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -167,6 +168,13 @@ function validateToolArgs(
|
||||||
) {
|
) {
|
||||||
return `Argument "${param.key}" must be an array of numbers`;
|
return `Argument "${param.key}" must be an array of numbers`;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
param.type === "string[]" &&
|
||||||
|
(!Array.isArray(value) ||
|
||||||
|
!value.every((item) => typeof item === "string"))
|
||||||
|
) {
|
||||||
|
return `Argument "${param.key}" must be an array of strings`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -310,6 +310,7 @@ describe("toGeminiTools", () => {
|
||||||
{ key: "name", type: "string", required: true },
|
{ key: "name", type: "string", required: true },
|
||||||
{ key: "count", type: "number", required: false },
|
{ key: "count", type: "number", required: false },
|
||||||
{ key: "times", type: "number[]", required: false },
|
{ key: "times", type: "number[]", required: false },
|
||||||
|
{ key: "elementIds", type: "string[]", required: false },
|
||||||
{ key: "active", type: "boolean", required: false },
|
{ key: "active", type: "boolean", required: false },
|
||||||
{ key: "meta", type: "object", required: false },
|
{ key: "meta", type: "object", required: false },
|
||||||
],
|
],
|
||||||
|
|
@ -326,6 +327,8 @@ describe("toGeminiTools", () => {
|
||||||
expect(props.count.type).toBe(SchemaType.NUMBER);
|
expect(props.count.type).toBe(SchemaType.NUMBER);
|
||||||
expect(props.times.type).toBe(SchemaType.ARRAY);
|
expect(props.times.type).toBe(SchemaType.ARRAY);
|
||||||
expect(props.times.items?.type).toBe(SchemaType.NUMBER);
|
expect(props.times.items?.type).toBe(SchemaType.NUMBER);
|
||||||
|
expect(props.elementIds.type).toBe(SchemaType.ARRAY);
|
||||||
|
expect(props.elementIds.items?.type).toBe(SchemaType.STRING);
|
||||||
expect(props.active.type).toBe(SchemaType.BOOLEAN);
|
expect(props.active.type).toBe(SchemaType.BOOLEAN);
|
||||||
expect(props.meta.type).toBe(SchemaType.OBJECT);
|
expect(props.meta.type).toBe(SchemaType.OBJECT);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,7 @@ const SIMPLE_TOOLS: ToolDefinition[] = [
|
||||||
{
|
{
|
||||||
name: "test_tool",
|
name: "test_tool",
|
||||||
description: "A test tool",
|
description: "A test tool",
|
||||||
parameters: [
|
parameters: [{ key: "input", type: "string", required: true }],
|
||||||
{ key: "input", type: "string", required: true },
|
|
||||||
],
|
|
||||||
execute: async () => ({}),
|
execute: async () => ({}),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -117,6 +115,49 @@ describe("OpenAICompatibleAdapter.chat()", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("maps array parameter schemas to OpenAI format", async () => {
|
||||||
|
mockCreate.mockResolvedValueOnce(makeOpenAIResponse({ content: "ok" }));
|
||||||
|
|
||||||
|
const adapter = new OpenAICompatibleAdapter(TEST_CONFIG);
|
||||||
|
await adapter.chat({
|
||||||
|
messages: [{ id: "1", role: "user", content: "Run tool", timestamp: 0 }],
|
||||||
|
systemPrompt: "System",
|
||||||
|
tools: [
|
||||||
|
{
|
||||||
|
name: "delete_timeline_elements",
|
||||||
|
description: "Delete elements",
|
||||||
|
parameters: [
|
||||||
|
{ key: "elementIds", type: "string[]", required: true },
|
||||||
|
{ key: "times", type: "number[]", required: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const callArgs = mockCreate.mock.calls[0][0] as {
|
||||||
|
tools: Array<{
|
||||||
|
function: {
|
||||||
|
parameters: {
|
||||||
|
properties: Record<
|
||||||
|
string,
|
||||||
|
{ type: string; items?: { type: string } }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = callArgs.tools[0].function.parameters.properties;
|
||||||
|
expect(props.elementIds).toEqual({
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
});
|
||||||
|
expect(props.times).toEqual({
|
||||||
|
type: "array",
|
||||||
|
items: { type: "number" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("prepends system prompt as first message", async () => {
|
test("prepends system prompt as first message", async () => {
|
||||||
mockCreate.mockResolvedValueOnce(makeOpenAIResponse({ content: "ok" }));
|
mockCreate.mockResolvedValueOnce(makeOpenAIResponse({ content: "ok" }));
|
||||||
|
|
||||||
|
|
@ -141,9 +182,7 @@ describe("OpenAICompatibleAdapter.chat()", () => {
|
||||||
|
|
||||||
const adapter = new OpenAICompatibleAdapter(TEST_CONFIG);
|
const adapter = new OpenAICompatibleAdapter(TEST_CONFIG);
|
||||||
await adapter.chat({
|
await adapter.chat({
|
||||||
messages: [
|
messages: [{ id: "1", role: "user", content: "Hello", timestamp: 0 }],
|
||||||
{ id: "1", role: "user", content: "Hello", timestamp: 0 },
|
|
||||||
],
|
|
||||||
systemPrompt: "System",
|
systemPrompt: "System",
|
||||||
tools: [],
|
tools: [],
|
||||||
});
|
});
|
||||||
|
|
@ -168,9 +207,7 @@ describe("OpenAICompatibleAdapter.chat()", () => {
|
||||||
id: "1",
|
id: "1",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Let me check.",
|
content: "Let me check.",
|
||||||
toolCalls: [
|
toolCalls: [{ id: "tc_1", name: "test_tool", args: { input: "x" } }],
|
||||||
{ id: "tc_1", name: "test_tool", args: { input: "x" } },
|
|
||||||
],
|
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,9 @@ function toGeminiParameterSchema(type: string): Schema {
|
||||||
if (type === "number[]") {
|
if (type === "number[]") {
|
||||||
return { type: SchemaType.ARRAY, items: { type: SchemaType.NUMBER } };
|
return { type: SchemaType.ARRAY, items: { type: SchemaType.NUMBER } };
|
||||||
}
|
}
|
||||||
|
if (type === "string[]") {
|
||||||
|
return { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } };
|
||||||
|
}
|
||||||
|
|
||||||
return { type: SchemaType.STRING };
|
return { type: SchemaType.STRING };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ interface OpenAIFunctionTool {
|
||||||
description: string;
|
description: string;
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object";
|
type: "object";
|
||||||
properties: Record<string, { type: string }>;
|
properties: Record<string, { type: string; items?: { type: string } }>;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -83,18 +83,25 @@ function toOpenAIFunctions(tools: ToolSchema[]): OpenAIFunctionTool[] {
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object" as const,
|
type: "object" as const,
|
||||||
properties: Object.fromEntries(
|
properties: Object.fromEntries(
|
||||||
tool.parameters.map((p) => [p.key, { type: p.type }]),
|
tool.parameters.map((p) => [p.key, toOpenAIParameterSchema(p.type)]),
|
||||||
),
|
),
|
||||||
...(tool.parameters.some((p) => p.required) && {
|
...(tool.parameters.some((p) => p.required) && {
|
||||||
required: tool.parameters
|
required: tool.parameters.filter((p) => p.required).map((p) => p.key),
|
||||||
.filter((p) => p.required)
|
|
||||||
.map((p) => p.key),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOpenAIParameterSchema(type: string): {
|
||||||
|
type: string;
|
||||||
|
items?: { type: string };
|
||||||
|
} {
|
||||||
|
if (type === "number[]") return { type: "array", items: { type: "number" } };
|
||||||
|
if (type === "string[]") return { type: "array", items: { type: "string" } };
|
||||||
|
return { type };
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Adapter implementation
|
// Adapter implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -138,8 +145,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
||||||
requestParams.tools = openaiTools;
|
requestParams.tools = openaiTools;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response =
|
const response = await this.client.chat.completions.create(requestParams);
|
||||||
await this.client.chat.completions.create(requestParams);
|
|
||||||
|
|
||||||
const choice = response.choices[0];
|
const choice = response.choices[0];
|
||||||
if (!choice) {
|
if (!choice) {
|
||||||
|
|
@ -149,12 +155,12 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
||||||
const content = choice.message.content ?? "";
|
const content = choice.message.content ?? "";
|
||||||
let toolCalls: ToolCall[] | undefined;
|
let toolCalls: ToolCall[] | undefined;
|
||||||
|
|
||||||
if (
|
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
|
||||||
choice.message.tool_calls &&
|
|
||||||
choice.message.tool_calls.length > 0
|
|
||||||
) {
|
|
||||||
toolCalls = choice.message.tool_calls
|
toolCalls = choice.message.tool_calls
|
||||||
.filter((tc): tc is Extract<typeof tc, { type: "function" }> => tc.type === "function")
|
.filter(
|
||||||
|
(tc): tc is Extract<typeof tc, { type: "function" }> =>
|
||||||
|
tc.type === "function",
|
||||||
|
)
|
||||||
.map((tc) => ({
|
.map((tc) => ({
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
name: tc.function.name,
|
name: tc.function.name,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
||||||
|
import type { AgentContext } from "@/agent/types";
|
||||||
|
import { toolRegistry } from "@/agent/tools/registry";
|
||||||
|
|
||||||
|
const mockDeleteTimelineElements = mock((_args: { elementIds: string[] }) => ({
|
||||||
|
success: true,
|
||||||
|
deletedElements: ["clip-1", "text-1"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module("@/agent/context", () => ({
|
||||||
|
EditorContextAdapter: {
|
||||||
|
deleteTimelineElements: mockDeleteTimelineElements,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
await import("@/agent/tools/delete-timeline-elements.tool");
|
||||||
|
|
||||||
|
const context: AgentContext = {
|
||||||
|
projectId: "proj-1",
|
||||||
|
activeSceneId: "scene-A",
|
||||||
|
mediaAssets: [],
|
||||||
|
playbackTimeMs: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("delete_timeline_elements tool", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDeleteTimelineElements.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is registered in the tool registry", () => {
|
||||||
|
expect(toolRegistry.has("delete_timeline_elements")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deletes timeline elements through the editor adapter", async () => {
|
||||||
|
const tool = toolRegistry.get("delete_timeline_elements");
|
||||||
|
const result = await tool.execute(
|
||||||
|
{ elementIds: ["clip-1", "text-1"] },
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDeleteTimelineElements).toHaveBeenCalledWith({
|
||||||
|
elementIds: ["clip-1", "text-1"],
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
deletedElements: ["clip-1", "text-1"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("validates element ids", async () => {
|
||||||
|
const tool = toolRegistry.get("delete_timeline_elements");
|
||||||
|
|
||||||
|
expect(await tool.execute({ elementIds: [] }, context)).toEqual({
|
||||||
|
error: "Invalid element ids",
|
||||||
|
});
|
||||||
|
expect(await tool.execute({ elementIds: ["clip-1", ""] }, context)).toEqual(
|
||||||
|
{
|
||||||
|
error: "Invalid element ids",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(await tool.execute({ elementIds: ["clip-1", 2] }, context)).toEqual({
|
||||||
|
error: "Invalid element ids",
|
||||||
|
});
|
||||||
|
expect(await tool.execute({ elementIds: "clip-1" }, context)).toEqual({
|
||||||
|
error: "Invalid element ids",
|
||||||
|
});
|
||||||
|
expect(mockDeleteTimelineElements).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { EditorContextAdapter } from "@/agent/context";
|
||||||
|
import type { AgentContext, ToolDefinition } from "@/agent/types";
|
||||||
|
import { toolRegistry } from "@/agent/tools/registry";
|
||||||
|
|
||||||
|
export type DeleteTimelineElementsArgs = {
|
||||||
|
elementIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteTimelineElementsResult = {
|
||||||
|
success: boolean;
|
||||||
|
deletedElements: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTimelineElementsTool: ToolDefinition = {
|
||||||
|
name: "delete_timeline_elements",
|
||||||
|
description:
|
||||||
|
"Deletes one or more timeline elements by elementId. Use list_timeline first to discover exact elementIds. To delete a time range, split at the range boundaries first, then delete the isolated elementIds.",
|
||||||
|
parameters: [{ key: "elementIds", type: "string[]", required: true }],
|
||||||
|
execute: async (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
_context: AgentContext,
|
||||||
|
): Promise<DeleteTimelineElementsResult | { error: string }> => {
|
||||||
|
const elementIds = args.elementIds;
|
||||||
|
|
||||||
|
if (!isValidElementIds(elementIds)) {
|
||||||
|
return { error: "Invalid element ids" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return EditorContextAdapter.deleteTimelineElements({ elementIds });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isValidElementIds(elementIds: unknown): elementIds is string[] {
|
||||||
|
return (
|
||||||
|
Array.isArray(elementIds) &&
|
||||||
|
elementIds.length > 0 &&
|
||||||
|
elementIds.every(
|
||||||
|
(elementId) =>
|
||||||
|
typeof elementId === "string" && elementId.trim().length > 0,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toolRegistry.register("delete_timeline_elements", deleteTimelineElementsTool);
|
||||||
|
|
@ -152,7 +152,7 @@ describe("POST /api/agent/chat", () => {
|
||||||
|
|
||||||
expect(callArgs.messages).toHaveLength(1);
|
expect(callArgs.messages).toHaveLength(1);
|
||||||
expect(callArgs.systemPrompt).toContain("NeuralCut");
|
expect(callArgs.systemPrompt).toContain("NeuralCut");
|
||||||
expect(callArgs.tools).toHaveLength(4);
|
expect(callArgs.tools).toHaveLength(5);
|
||||||
expect(
|
expect(
|
||||||
callArgs.tools.map((tool) => (tool as { name: string }).name),
|
callArgs.tools.map((tool) => (tool as { name: string }).name),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
|
|
@ -160,6 +160,7 @@ describe("POST /api/agent/chat", () => {
|
||||||
"list_project_assets",
|
"list_project_assets",
|
||||||
"list_timeline",
|
"list_timeline",
|
||||||
"split",
|
"split",
|
||||||
|
"delete_timeline_elements",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ const providerToolSchemas: ToolSchema[] = [
|
||||||
"Splits timeline elements at one or more requested timeline times in seconds without deleting, trimming, or moving content. Use one time for a single cut, or multiple times to isolate ranges before separate edit/delete operations.",
|
"Splits timeline elements at one or more requested timeline times in seconds without deleting, trimming, or moving content. Use one time for a single cut, or multiple times to isolate ranges before separate edit/delete operations.",
|
||||||
parameters: [{ key: "times", type: "number[]", required: true }],
|
parameters: [{ key: "times", type: "number[]", required: true }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "delete_timeline_elements",
|
||||||
|
description:
|
||||||
|
"Deletes one or more timeline elements by elementId. Use list_timeline first to discover exact elementIds. To delete a time range, split at the range boundaries first, then delete the isolated elementIds.",
|
||||||
|
parameters: [{ key: "elementIds", type: "string[]", required: true }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,17 @@ const TOOL_CALL_FORMATTERS: Record<
|
||||||
description: `at ${formatted}`,
|
description: `at ${formatted}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
delete_timeline_elements: (args) => {
|
||||||
|
const elementIds = args.elementIds as string[] | undefined;
|
||||||
|
const count = elementIds?.length ?? 0;
|
||||||
|
return {
|
||||||
|
label: "Delete",
|
||||||
|
description:
|
||||||
|
count > 0
|
||||||
|
? `${count} element${count !== 1 ? "s" : ""}`
|
||||||
|
: "No elements specified",
|
||||||
|
};
|
||||||
|
},
|
||||||
load_context: (args) => {
|
load_context: (args) => {
|
||||||
const targetType = String(args.targetType ?? "unknown");
|
const targetType = String(args.targetType ?? "unknown");
|
||||||
const id = args.id ?? args.assetId ?? args.elementId;
|
const id = args.id ?? args.assetId ?? args.elementId;
|
||||||
|
|
@ -134,6 +145,20 @@ const TOOL_RESULT_FORMATTERS: Record<
|
||||||
: "Failed",
|
: "Failed",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
delete_timeline_elements: (parsed) => {
|
||||||
|
const data = parsed as {
|
||||||
|
success?: boolean;
|
||||||
|
deletedElements?: string[];
|
||||||
|
} | null;
|
||||||
|
if (!data) return null;
|
||||||
|
const count = data.deletedElements?.length ?? 0;
|
||||||
|
return {
|
||||||
|
label: "Deleted",
|
||||||
|
description: data.success
|
||||||
|
? `${count} element${count !== 1 ? "s" : ""}`
|
||||||
|
: "Failed",
|
||||||
|
};
|
||||||
|
},
|
||||||
load_context: (parsed) => {
|
load_context: (parsed) => {
|
||||||
const data = parsed as {
|
const data = parsed as {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,42 @@ Dividir elementos del timeline en uno o más puntos de tiempo, sin borrar conten
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. `add_text`
|
## 5. `delete_timeline_elements`
|
||||||
|
|
||||||
|
### Propósito
|
||||||
|
Eliminar uno o más elementos concretos del timeline por `elementId`. Para eliminar un rango de tiempo, el agente debe primero usar `split({ times: [start, end] })` y luego borrar los elementos aislados.
|
||||||
|
|
||||||
|
### Input
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
elementIds: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
success: boolean;
|
||||||
|
deletedElements: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- MUST validate `elementIds` contains at least one non-empty string.
|
||||||
|
- MUST use `list_timeline` first when exact `elementId` values are unknown.
|
||||||
|
- MUST resolve `elementId` values against the active timeline before mutating.
|
||||||
|
- MUST fail without mutating if any requested element is missing.
|
||||||
|
- MUST remove only the requested elements.
|
||||||
|
- MUST preserve undo/redo behavior if supported.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
- Invalid ids: `{ error: "Invalid element ids" }`.
|
||||||
|
- Missing element: `{ error: "Timeline elements not found: <ids>" }`.
|
||||||
|
- Empty timeline: `{ error: "No timeline content" }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `add_text`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Agregar texto visual al timeline. Esta primitive cubre títulos, hooks, labels y subtítulos básicos.
|
Agregar texto visual al timeline. Esta primitive cubre títulos, hooks, labels y subtítulos básicos.
|
||||||
|
|
@ -220,7 +255,7 @@ Agregar texto visual al timeline. Esta primitive cubre títulos, hooks, labels y
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. `update_text`
|
## 7. `update_text`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Editar un texto existente en el timeline.
|
Editar un texto existente en el timeline.
|
||||||
|
|
@ -258,7 +293,7 @@ Editar un texto existente en el timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. `add_media_to_timeline`
|
## 8. `add_media_to_timeline`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Agregar un asset existente al timeline.
|
Agregar un asset existente al timeline.
|
||||||
|
|
@ -292,10 +327,10 @@ Agregar un asset existente al timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. `delete_element`
|
## 9. `delete_element` *(deprecated in favor of `delete_timeline_elements`)*
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Eliminar un elemento específico del timeline.
|
Eliminar un elemento específico del timeline. La implementación actual debe preferir `delete_timeline_elements` porque soporta borrado en lote y permite componer rangos después de `split`.
|
||||||
|
|
||||||
### Input
|
### Input
|
||||||
```ts
|
```ts
|
||||||
|
|
@ -322,7 +357,7 @@ Eliminar un elemento específico del timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. `set_volume`
|
## 10. `set_volume`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Ajustar volumen de un elemento de audio o video.
|
Ajustar volumen de un elemento de audio o video.
|
||||||
|
|
@ -355,7 +390,7 @@ Ajustar volumen de un elemento de audio o video.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. `add_sticker`
|
## 11. `add_sticker`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Agregar un sticker existente al timeline.
|
Agregar un sticker existente al timeline.
|
||||||
|
|
@ -390,7 +425,7 @@ Agregar un sticker existente al timeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. `apply_effect`
|
## 12. `apply_effect`
|
||||||
|
|
||||||
### Propósito
|
### Propósito
|
||||||
Aplicar un efecto existente a un clip. En el estado actual del repo, el efecto real disponible parece ser `blur`.
|
Aplicar un efecto existente a un clip. En el estado actual del repo, el efecto real disponible parece ser `blur`.
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ Uso:
|
||||||
- separar intro/outro sin eliminar nada,
|
- separar intro/outro sin eliminar nada,
|
||||||
- dejarle al agente una primitive composable para flujos más grandes.
|
- dejarle al agente una primitive composable para flujos más grandes.
|
||||||
|
|
||||||
> `split` NO elimina material. Si el usuario pide “eliminá esta parte”, el agente debe componer `split` con una tool de borrado cuando exista.
|
> `split` NO elimina material. Si el usuario pide “eliminá esta parte”, el agente debe componer `split` con `delete_timeline_elements`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -130,14 +130,13 @@ Uso:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `delete_element`
|
### `delete_timeline_elements`
|
||||||
|
|
||||||
Elimina un elemento específico del timeline.
|
Elimina uno o más elementos específicos del timeline.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{
|
{
|
||||||
trackId: string;
|
elementIds: string[];
|
||||||
elementId: string;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -147,6 +146,8 @@ Uso:
|
||||||
- borrar stickers,
|
- borrar stickers,
|
||||||
- borrar efectos standalone si aplica.
|
- borrar efectos standalone si aplica.
|
||||||
|
|
||||||
|
> Para borrar un rango, el agente debe hacer `split({ times: [start, end] })`, volver a listar/identificar los elementos aislados si hace falta, y luego llamar `delete_timeline_elements`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### `add_text`
|
### `add_text`
|
||||||
|
|
@ -295,7 +296,7 @@ Descartada porque `analysisType` introduce categorías artificiales. Gemini debe
|
||||||
|
|
||||||
### `remove_silences`
|
### `remove_silences`
|
||||||
|
|
||||||
Diferida/no prioritaria porque se puede expresar como flujo con `split` + tool de borrado.
|
Diferida/no prioritaria porque se puede expresar como flujo con `split` + `delete_timeline_elements`.
|
||||||
|
|
||||||
### `generate_subtitles`
|
### `generate_subtitles`
|
||||||
|
|
||||||
|
|
@ -313,9 +314,9 @@ Diferida porque el repo actual no parece tener corrección de color/LUTs impleme
|
||||||
2. `list_timeline`
|
2. `list_timeline`
|
||||||
3. `load_asset_context`
|
3. `load_asset_context`
|
||||||
4. `split`
|
4. `split`
|
||||||
5. `add_text`
|
5. `delete_timeline_elements`
|
||||||
6. `add_media_to_timeline`
|
6. `add_text`
|
||||||
7. `delete_element`
|
7. `add_media_to_timeline`
|
||||||
8. `set_volume`
|
8. `set_volume`
|
||||||
9. `add_sticker`
|
9. `add_sticker`
|
||||||
10. `apply_effect`
|
10. `apply_effect`
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ Decisiones vigentes:
|
||||||
Roadmap vigente de alto nivel:
|
Roadmap vigente de alto nivel:
|
||||||
|
|
||||||
1. **Contexto y percepción:** `list_project_assets`, `list_timeline`, `load_asset_context`.
|
1. **Contexto y percepción:** `list_project_assets`, `list_timeline`, `load_asset_context`.
|
||||||
2. **Edición básica:** `split`, `add_text`, `add_media_to_timeline`, `delete_element`.
|
2. **Edición básica:** `split`, `delete_timeline_elements`, `add_text`, `add_media_to_timeline`.
|
||||||
3. **Ajustes simples:** `set_volume`, `add_sticker`, `apply_effect`.
|
3. **Ajustes simples:** `set_volume`, `add_sticker`, `apply_effect`.
|
||||||
4. **UX avanzada:** referencias `@asset`, progreso de uploads/procesamiento, previews/aprobaciones.
|
4. **UX avanzada:** referencias `@asset`, progreso de uploads/procesamiento, previews/aprobaciones.
|
||||||
|
|
||||||
|
|
@ -422,10 +422,11 @@ Estas son las tools que el equipo debe priorizar. Son suficientemente pequeñas
|
||||||
| `list_timeline` | Devuelve resumen estructurado del timeline con `trackId`/`elementId` | Prioridad alta |
|
| `list_timeline` | Devuelve resumen estructurado del timeline con `trackId`/`elementId` | Prioridad alta |
|
||||||
| `load_asset_context` | Carga un asset en el contexto multimodal de Gemini y cachea la referencia | Prioridad crítica / killer feature |
|
| `load_asset_context` | Carga un asset en el contexto multimodal de Gemini y cachea la referencia | Prioridad crítica / killer feature |
|
||||||
| `split` | Hace cortes puntuales en uno o más timestamps sin borrar contenido | Prioridad alta |
|
| `split` | Hace cortes puntuales en uno o más timestamps sin borrar contenido | Prioridad alta |
|
||||||
|
| `delete_timeline_elements` | Borra uno o más elementos concretos del timeline por `elementId` | Prioridad alta |
|
||||||
| `add_text` | Agrega texto visual: hooks, títulos, labels, subtítulos básicos | Prioridad alta |
|
| `add_text` | Agrega texto visual: hooks, títulos, labels, subtítulos básicos | Prioridad alta |
|
||||||
| `update_text` | Modifica texto existente | Prioridad media |
|
| `update_text` | Modifica texto existente | Prioridad media |
|
||||||
| `add_media_to_timeline` | Inserta video/audio/imagen existente al timeline | Prioridad alta |
|
| `add_media_to_timeline` | Inserta video/audio/imagen existente al timeline | Prioridad alta |
|
||||||
| `delete_element` | Borra un elemento específico del timeline | Prioridad alta |
|
| `delete_element` | Reemplazada por `delete_timeline_elements` para soportar borrado en lote | Baja |
|
||||||
| `set_volume` | Ajusta volumen de audio/video | Prioridad media |
|
| `set_volume` | Ajusta volumen de audio/video | Prioridad media |
|
||||||
| `add_sticker` | Inserta sticker existente | Prioridad media |
|
| `add_sticker` | Inserta sticker existente | Prioridad media |
|
||||||
| `apply_effect` | Aplica efectos existentes; por ahora principalmente `blur` | Prioridad baja/media |
|
| `apply_effect` | Aplica efectos existentes; por ahora principalmente `blur` | Prioridad baja/media |
|
||||||
|
|
@ -716,7 +717,7 @@ Usuario: "quita la parte donde me equivoco"
|
||||||
Agente: watch_video("where does the person mess up?")
|
Agente: watch_video("where does the person mess up?")
|
||||||
→ identifica timestamp 1:23-1:31
|
→ identifica timestamp 1:23-1:31
|
||||||
→ pide confirmación
|
→ pide confirmación
|
||||||
→ split({ times: [start, end] }) + tool de borrado
|
→ split({ times: [start, end] }) + delete_timeline_elements
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -840,7 +841,7 @@ Esto no reemplaza la primera feature de producto; la **habilita**.
|
||||||
|
|
||||||
**Should-have actualizado:**
|
**Should-have actualizado:**
|
||||||
- Como usuario, puedo insertar assets existentes al timeline con `add_media_to_timeline`.
|
- Como usuario, puedo insertar assets existentes al timeline con `add_media_to_timeline`.
|
||||||
- Como usuario, puedo borrar elementos específicos con `delete_element`.
|
- Como usuario, puedo borrar elementos específicos con `delete_timeline_elements`.
|
||||||
- Como usuario, puedo ajustar volumen con `set_volume`.
|
- Como usuario, puedo ajustar volumen con `set_volume`.
|
||||||
- Como usuario, puedo agregar stickers con `add_sticker`.
|
- Como usuario, puedo agregar stickers con `add_sticker`.
|
||||||
- Como usuario, puedo aplicar efectos existentes con `apply_effect` (inicialmente `blur`).
|
- Como usuario, puedo aplicar efectos existentes con `apply_effect` (inicialmente `blur`).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue