feat: add agent delete tool

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-24 21:04:42 -05:00
parent 7c19c72058
commit 1762661309
15 changed files with 378 additions and 46 deletions

View File

@ -63,6 +63,13 @@ const testTool: ToolDefinition = {
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
* orchestrator's resolveToolCalls catch block works at runtime. */
const throwingTool: ToolDefinition = {
@ -78,6 +85,7 @@ const throwingTool: ToolDefinition = {
// Register once (registry is a singleton — re-registering overwrites)
toolRegistry.register("_test_tool", testTool);
toolRegistry.register("_test_string_array_tool", stringArrayTool);
toolRegistry.register("_test_throwing_tool", throwingTool);
// ---------------------------------------------------------------------------
@ -221,8 +229,8 @@ describe("orchestrator", () => {
const chat = useChatStore.getState();
const agent = useAgentStore.getState();
// 8 iterations × (1 assistant + 1 tool_result) + 1 cap message = 17
expect(chat.messages).toHaveLength(17);
// 20 iterations × (1 assistant + 1 tool_result) + 1 cap message = 41
expect(chat.messages).toHaveLength(41);
// Last message is the cap message
const lastMsg = chat.messages[chat.messages.length - 1];
@ -360,6 +368,36 @@ describe("orchestrator", () => {
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
// -----------------------------------------------------------------------

View File

@ -72,6 +72,39 @@ export const EditorContextAdapter = {
});
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 {
@ -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";

View File

@ -10,10 +10,11 @@ import "@/agent/tools/load-context.tool";
import "@/agent/tools/list-project-assets.tool";
import "@/agent/tools/list-timeline.tool";
import "@/agent/tools/split.tool";
import "@/agent/tools/delete-timeline-elements.tool";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
const MAX_ITERATIONS = 8;
const MAX_ITERATIONS = 20;
interface APIResponse {
content: string;
@ -167,6 +168,13 @@ function validateToolArgs(
) {
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;

View File

@ -310,6 +310,7 @@ describe("toGeminiTools", () => {
{ key: "name", type: "string", required: true },
{ key: "count", type: "number", required: false },
{ key: "times", type: "number[]", required: false },
{ key: "elementIds", type: "string[]", required: false },
{ key: "active", type: "boolean", required: false },
{ key: "meta", type: "object", required: false },
],
@ -326,6 +327,8 @@ describe("toGeminiTools", () => {
expect(props.count.type).toBe(SchemaType.NUMBER);
expect(props.times.type).toBe(SchemaType.ARRAY);
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.meta.type).toBe(SchemaType.OBJECT);
});

View File

@ -35,9 +35,7 @@ const SIMPLE_TOOLS: ToolDefinition[] = [
{
name: "test_tool",
description: "A test tool",
parameters: [
{ key: "input", type: "string", required: true },
],
parameters: [{ key: "input", type: "string", required: true }],
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 () => {
mockCreate.mockResolvedValueOnce(makeOpenAIResponse({ content: "ok" }));
@ -141,9 +182,7 @@ describe("OpenAICompatibleAdapter.chat()", () => {
const adapter = new OpenAICompatibleAdapter(TEST_CONFIG);
await adapter.chat({
messages: [
{ id: "1", role: "user", content: "Hello", timestamp: 0 },
],
messages: [{ id: "1", role: "user", content: "Hello", timestamp: 0 }],
systemPrompt: "System",
tools: [],
});
@ -168,9 +207,7 @@ describe("OpenAICompatibleAdapter.chat()", () => {
id: "1",
role: "assistant",
content: "Let me check.",
toolCalls: [
{ id: "tc_1", name: "test_tool", args: { input: "x" } },
],
toolCalls: [{ id: "tc_1", name: "test_tool", args: { input: "x" } }],
timestamp: 0,
},
],

View File

@ -258,6 +258,9 @@ function toGeminiParameterSchema(type: string): Schema {
if (type === "number[]") {
return { type: SchemaType.ARRAY, items: { type: SchemaType.NUMBER } };
}
if (type === "string[]") {
return { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } };
}
return { type: SchemaType.STRING };
}

View File

@ -17,7 +17,7 @@ interface OpenAIFunctionTool {
description: string;
parameters: {
type: "object";
properties: Record<string, { type: string }>;
properties: Record<string, { type: string; items?: { type: string } }>;
required?: string[];
};
};
@ -83,18 +83,25 @@ function toOpenAIFunctions(tools: ToolSchema[]): OpenAIFunctionTool[] {
parameters: {
type: "object" as const,
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) && {
required: tool.parameters
.filter((p) => p.required)
.map((p) => p.key),
required: tool.parameters.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
// ---------------------------------------------------------------------------
@ -138,8 +145,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
requestParams.tools = openaiTools;
}
const response =
await this.client.chat.completions.create(requestParams);
const response = await this.client.chat.completions.create(requestParams);
const choice = response.choices[0];
if (!choice) {
@ -149,12 +155,12 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
const content = choice.message.content ?? "";
let toolCalls: ToolCall[] | undefined;
if (
choice.message.tool_calls &&
choice.message.tool_calls.length > 0
) {
if (choice.message.tool_calls && choice.message.tool_calls.length > 0) {
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) => ({
id: tc.id,
name: tc.function.name,

View File

@ -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();
});
});

View File

@ -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);

View File

@ -152,7 +152,7 @@ describe("POST /api/agent/chat", () => {
expect(callArgs.messages).toHaveLength(1);
expect(callArgs.systemPrompt).toContain("NeuralCut");
expect(callArgs.tools).toHaveLength(4);
expect(callArgs.tools).toHaveLength(5);
expect(
callArgs.tools.map((tool) => (tool as { name: string }).name),
).toEqual([
@ -160,6 +160,7 @@ describe("POST /api/agent/chat", () => {
"list_project_assets",
"list_timeline",
"split",
"delete_timeline_elements",
]);
});

View File

@ -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.",
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 }],
},
];
// ---------------------------------------------------------------------------

View File

@ -77,6 +77,17 @@ const TOOL_CALL_FORMATTERS: Record<
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) => {
const targetType = String(args.targetType ?? "unknown");
const id = args.id ?? args.assetId ?? args.elementId;
@ -134,6 +145,20 @@ const TOOL_RESULT_FORMATTERS: Record<
: "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) => {
const data = parsed as {
status?: string;

View File

@ -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
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
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
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
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
```ts
@ -322,7 +357,7 @@ Eliminar un elemento específico del timeline.
---
## 9. `set_volume`
## 10. `set_volume`
### Propósito
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
Agregar un sticker existente al timeline.
@ -390,7 +425,7 @@ Agregar un sticker existente al timeline.
---
## 11. `apply_effect`
## 12. `apply_effect`
### Propósito
Aplicar un efecto existente a un clip. En el estado actual del repo, el efecto real disponible parece ser `blur`.

View File

@ -106,7 +106,7 @@ Uso:
- separar intro/outro sin eliminar nada,
- 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
{
trackId: string;
elementId: string;
elementIds: string[];
}
```
@ -147,6 +146,8 @@ Uso:
- borrar stickers,
- 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`
@ -295,7 +296,7 @@ Descartada porque `analysisType` introduce categorías artificiales. Gemini debe
### `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`
@ -313,9 +314,9 @@ Diferida porque el repo actual no parece tener corrección de color/LUTs impleme
2. `list_timeline`
3. `load_asset_context`
4. `split`
5. `add_text`
6. `add_media_to_timeline`
7. `delete_element`
5. `delete_timeline_elements`
6. `add_text`
7. `add_media_to_timeline`
8. `set_volume`
9. `add_sticker`
10. `apply_effect`

View File

@ -50,7 +50,7 @@ Decisiones vigentes:
Roadmap vigente de alto nivel:
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`.
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 |
| `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 |
| `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 |
| `update_text` | Modifica texto existente | Prioridad media |
| `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 |
| `add_sticker` | Inserta sticker existente | Prioridad 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?")
→ identifica timestamp 1:23-1:31
→ 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:**
- 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 agregar stickers con `add_sticker`.
- Como usuario, puedo aplicar efectos existentes con `apply_effect` (inicialmente `blur`).