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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import Database from "better-sqlite3";
import { validateToolCall, type Tool as PiAiTool, type ToolCall } from "@mariozechner/pi-ai";
import { ensureSchema } from "../../../../../memory/schema.js";
import { getTaskStore } from "../../../../../memory/agent/tasks.js";
import { telegramCreateScheduledTaskExecutor } from "../create-scheduled-task.js";
import {
telegramCreateScheduledTaskExecutor,
telegramCreateScheduledTaskTool,
} from "../create-scheduled-task.js";
import { telegramUpdateTaskExecutor } from "../update-task.js";
import { telegramGetTaskExecutor } from "../get-task.js";
import { telegramListTasksExecutor } from "../list-tasks.js";
Expand Down Expand Up @@ -131,6 +135,32 @@ describe("telegram_create_scheduled_task with recurrence", () => {
db.close();
});

it("validates object payloads through the tool schema", () => {
const payload = {
type: "tool_call",
tool: "journal_query",
params: { limit: 5 },
condition: "daily report",
};
const toolCall: ToolCall = {
type: "toolCall",
id: "call-1",
name: "telegram_create_scheduled_task",
arguments: {
description: "Run journal query",
scheduleDate: futureDate(300),
payload,
},
};

const validated = validateToolCall(
[telegramCreateScheduledTaskTool as unknown as PiAiTool],
toolCall
) as { payload: unknown };

expect(validated.payload).toEqual(payload);
});

it("creates recurring task with recurrence string", async () => {
const ctx = makeContext(db);
const result = await telegramCreateScheduledTaskExecutor(
Expand Down Expand Up @@ -158,6 +188,30 @@ describe("telegram_create_scheduled_task with recurrence", () => {
expect(task?.recurrenceInterval).toBe(2700);
});

it("accepts payload objects parsed from JSON tool-call arguments", async () => {
const ctx = makeContext(db);
const payload = {
type: "agent_task",
instructions: "Review the latest TON price and write a journal note",
context: { source: "scheduler" },
};

const result = await telegramCreateScheduledTaskExecutor(
{
description: "Review TON price",
scheduleDate: futureDate(300),
payload,
},
ctx
);

expect(result.success).toBe(true);

const taskId = (result.data as { taskId: string }).taskId;
const task = getTaskStore(db).getTask(taskId);
expect(task?.payload).toBe(JSON.stringify(payload));
});

it("returns undefined recurrenceInterval for non-recurring tasks", async () => {
const ctx = makeContext(db);
const result = await telegramCreateScheduledTaskExecutor(
Expand Down Expand Up @@ -289,6 +343,26 @@ describe("telegram_update_task", () => {
expect(updated?.payload).toBe(newPayload);
});

it("updates task payload from objects parsed from JSON tool-call arguments", async () => {
const store = getTaskStore(db);
const task = store.createTask({ description: "task" });
const newPayload = {
type: "tool_call",
tool: "journal_query",
params: { limit: 5 },
condition: "daily report",
};

const result = await telegramUpdateTaskExecutor(
{ taskId: task.id, payload: newPayload },
makeContext(db)
);

expect(result.success).toBe(true);
const updated = store.getTask(task.id);
expect(updated?.payload).toBe(JSON.stringify(newPayload));
});

it("clears payload when empty string provided", async () => {
const store = getTaskStore(db);
const task = store.createTask({
Expand Down
89 changes: 21 additions & 68 deletions src/agent/tools/telegram/tasks/create-scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ import { randomLong } from "../../../../utils/gramjs-bigint.js";
import { MAX_DEPENDENTS_PER_TASK } from "../../../../constants/limits.js";
import { getErrorMessage } from "../../../../utils/errors.js";
import { createLogger } from "../../../../utils/logger.js";
import {
normalizeScheduledTaskPayload,
scheduledTaskPayloadSchema,
type ScheduledTaskPayloadInput,
validateScheduledTaskPayload,
} from "./payload.js";

const log = createLogger("Tools");

Expand Down Expand Up @@ -61,7 +67,7 @@ export function parseRecurrenceInterval(recurrence: string): number | null {
interface CreateScheduledTaskParams {
description: string;
scheduleDate?: string;
payload?: string;
payload?: ScheduledTaskPayloadInput;
reason?: string;
priority?: number;
dependsOn?: string[];
Expand Down Expand Up @@ -112,22 +118,7 @@ export const telegramCreateScheduledTaskTool: Tool = {
"When to execute the task (ISO 8601 format, e.g., '2024-12-25T10:00:00Z' or Unix timestamp). Optional if dependsOn is provided - task will execute when dependencies complete.",
})
),
payload: Type.Optional(
Type.String({
description: `JSON payload defining what to execute automatically. Two types:

1. Simple tool call (auto-executed, result fed to you):
{"type":"tool_call","tool":"ton_get_price","params":{},"condition":"price > 5"}

2. Complex agent task — multi-step instructions the agent executes (e.g., trading automation):
{"type":"agent_task","instructions":"1. Run trade simulation\\n2. Check journal for results\\n3. Send report via telegram_send_message","context":{"chatId":"123"}}

3. Skip on parent failure (continues even if parent fails):
{"type":"agent_task","instructions":"Send daily report","skipOnParentFailure":false}

If omitted, task is a simple reminder.`,
})
),
payload: Type.Optional(scheduledTaskPayloadSchema),
reason: Type.Optional(
Type.String({
description: "Why you're scheduling this task (helps with context when executing)",
Expand Down Expand Up @@ -179,14 +170,23 @@ export const telegramCreateScheduledTaskExecutor: ToolExecutor<CreateScheduledTa
const {
description,
scheduleDate,
payload,
payload: rawPayload,
reason,
priority,
dependsOn,
recurrence,
recurrenceUntil,
} = params;

const normalizedPayload = normalizeScheduledTaskPayload(rawPayload);
if (!normalizedPayload.success) {
return {
success: false,
error: normalizedPayload.error,
};
}
const payload = normalizedPayload.payload;

// Validate: either scheduleDate OR dependsOn must be provided
if (!scheduleDate && (!dependsOn || dependsOn.length === 0)) {
return {
Expand Down Expand Up @@ -255,58 +255,11 @@ export const telegramCreateScheduledTaskExecutor: ToolExecutor<CreateScheduledTa

// Validate payload if provided
if (payload) {
try {
const parsed = JSON.parse(payload);
if (!parsed.type || !["tool_call", "agent_task"].includes(parsed.type)) {
return {
success: false,
error: 'Payload must have type "tool_call" or "agent_task"',
};
}

// Validate tool_call payload
if (parsed.type === "tool_call") {
if (!parsed.tool || typeof parsed.tool !== "string") {
return {
success: false,
error: 'tool_call payload requires "tool" field (string)',
};
}
if (parsed.params !== undefined && typeof parsed.params !== "object") {
return {
success: false,
error: 'tool_call payload "params" must be an object',
};
}
// Note: Tool existence is validated at execution time by the executor.
// We can't easily validate here as tool registry isn't in ToolContext.
}

// Validate agent_task payload
if (parsed.type === "agent_task") {
if (!parsed.instructions || typeof parsed.instructions !== "string") {
return {
success: false,
error: 'agent_task payload requires "instructions" field (string)',
};
}
if (parsed.instructions.length < 5) {
return {
success: false,
error: "Instructions too short (min 5 characters)",
};
}
if (parsed.context !== undefined && typeof parsed.context !== "object") {
return {
success: false,
error: 'agent_task payload "context" must be an object',
};
}
}
} catch {
const error = validateScheduledTaskPayload(payload);
if (error) {
return {
success: false,
error: "Invalid JSON payload",
error,
};
}
}
Expand Down
103 changes: 103 additions & 0 deletions src/agent/tools/telegram/tasks/payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Type } from "@sinclair/typebox";

export type ScheduledTaskPayloadInput = string | Record<string, unknown>;

const scheduledTaskPayloadDescription = `JSON payload defining what to execute automatically. Accepts either a JSON string or an already-parsed JSON object. Two types:
1. Tool call: {"type":"tool_call","tool":"ton_get_price","params":{},"condition":"price > 5"}
2. Agent task: {"type":"agent_task","instructions":"Do something","context":{}}
3. Agent task that continues after parent failure: {"type":"agent_task","instructions":"Send daily report","skipOnParentFailure":false}
If omitted, task is a simple reminder.`;

function createScheduledTaskPayloadSchema(description: string) {
return Type.Union(
[
Type.String(),
Type.Object(
{},
{
additionalProperties: true,
}
),
],
{
description,
}
);
}

export const scheduledTaskPayloadSchema = createScheduledTaskPayloadSchema(
scheduledTaskPayloadDescription
);

export const scheduledTaskPayloadUpdateSchema = createScheduledTaskPayloadSchema(
`${scheduledTaskPayloadDescription}
Set to empty string "" to convert to a simple reminder with no automatic execution.`
);

type NormalizedPayload =
| { success: true; payload: string | undefined }
| { success: false; error: string };

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

export function normalizeScheduledTaskPayload(
payload: ScheduledTaskPayloadInput | undefined
): NormalizedPayload {
if (payload === undefined) {
return { success: true, payload: undefined };
}

if (typeof payload === "string") {
return { success: true, payload };
}

if (!isRecord(payload)) {
return { success: false, error: "Payload must be a JSON string or object" };
}

try {
return { success: true, payload: JSON.stringify(payload) };
} catch {
return { success: false, error: "Invalid JSON payload" };
}
}

export function validateScheduledTaskPayload(payload: string): string | null {
try {
const parsed = JSON.parse(payload) as unknown;
if (!isRecord(parsed)) {
return "Invalid JSON payload";
}

if (!parsed.type || !["tool_call", "agent_task"].includes(String(parsed.type))) {
return 'Payload must have type "tool_call" or "agent_task"';
}

if (parsed.type === "tool_call") {
if (!parsed.tool || typeof parsed.tool !== "string") {
return 'tool_call payload requires "tool" field (string)';
}
if (parsed.params !== undefined && !isRecord(parsed.params)) {
return 'tool_call payload "params" must be an object';
}
}

if (parsed.type === "agent_task") {
if (!parsed.instructions || typeof parsed.instructions !== "string") {
return 'agent_task payload requires "instructions" field (string)';
}
if (parsed.instructions.length < 5) {
return "Instructions too short (min 5 characters)";
}
if (parsed.context !== undefined && !isRecord(parsed.context)) {
return 'agent_task payload "context" must be an object';
}
}

return null;
} catch {
return "Invalid JSON payload";
}
}
Loading
Loading