@fickydev/pigent 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/PLAN.md +2 -2
- package/TODO.md +20 -13
- package/agents/assistant/agent.yaml +0 -5
- package/drizzle/migrations/0002_bouncy_leper_queen.sql +13 -0
- package/drizzle/migrations/0003_secret_stone_men.sql +11 -0
- package/drizzle/migrations/meta/0002_snapshot.json +606 -0
- package/drizzle/migrations/meta/0003_snapshot.json +681 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +1 -1
- package/pigent.yaml +18 -1
- package/src/agents/BotCommandHandler.ts +127 -23
- package/src/config/loadConfig.ts +1 -0
- package/src/config/schemas.ts +14 -8
- package/src/daemon/AgentDaemon.ts +8 -1
- package/src/daemon/Scheduler.ts +224 -0
- package/src/daemon/taskDue.ts +4 -0
- package/src/db/repositories/TaskConfigRepository.ts +63 -0
- package/src/db/repositories/TaskRepository.ts +129 -0
- package/src/db/repositories/index.ts +4 -0
- package/src/db/schema.ts +30 -0
- package/src/pi/PiAvailableModels.ts +23 -0
package/pigent.yaml
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
telegramChats: []
|
|
2
2
|
modelChoices: []
|
|
3
|
+
scheduler:
|
|
4
|
+
tickIntervalMs: 10000
|
|
5
|
+
tasks: []
|
|
3
6
|
|
|
4
|
-
#
|
|
7
|
+
# Optional. If empty, /model uses Pi's currently available models automatically.
|
|
8
|
+
# Add choices to curate or rename buttons for /model.
|
|
5
9
|
#
|
|
6
10
|
# modelChoices:
|
|
7
11
|
# - id: anthropic/claude-sonnet-4-5
|
|
@@ -19,3 +23,16 @@ modelChoices: []
|
|
|
19
23
|
# - assistant
|
|
20
24
|
# instructions: |
|
|
21
25
|
# Be concise.
|
|
26
|
+
#
|
|
27
|
+
# Scheduled tasks. Each task runs on interval and sends output to channel.
|
|
28
|
+
# If the agent returns "NOOP", nothing is sent.
|
|
29
|
+
#
|
|
30
|
+
# scheduler:
|
|
31
|
+
# tickIntervalMs: 10000
|
|
32
|
+
# tasks:
|
|
33
|
+
# - id: monitor-build
|
|
34
|
+
# agent: assistant
|
|
35
|
+
# intervalMs: 30000
|
|
36
|
+
# prompt: Check the build pipeline. NOOP if nothing to report.
|
|
37
|
+
# channel: telegram
|
|
38
|
+
# chatId: "-100123456"
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { InboundMessage, InlineKeyboardButton } from "../channels/types";
|
|
2
2
|
import type { ModelChoiceConfig } from "../config/schemas";
|
|
3
3
|
import type { Repositories } from "../db/repositories";
|
|
4
|
+
import { getAvailableModelChoices } from "../pi/PiAvailableModels";
|
|
4
5
|
import { isValidModelRef } from "../pi/PiModelResolver";
|
|
5
6
|
import type { AgentRegistry } from "./AgentRegistry";
|
|
7
|
+
import type { Scheduler } from "../daemon/Scheduler";
|
|
6
8
|
|
|
7
9
|
export type BotCommandResult =
|
|
8
10
|
| {
|
|
@@ -22,12 +24,15 @@ export class BotCommandHandler {
|
|
|
22
24
|
private readonly registry: AgentRegistry,
|
|
23
25
|
private readonly repositories: Repositories,
|
|
24
26
|
private readonly modelChoices: ModelChoiceConfig[] = [],
|
|
27
|
+
private readonly scheduler?: Scheduler,
|
|
25
28
|
) {}
|
|
26
29
|
|
|
27
30
|
async handle(message: InboundMessage): Promise<BotCommandResult> {
|
|
28
31
|
const [command] = message.text.trim().split(/\s+/, 1);
|
|
29
32
|
|
|
30
33
|
switch (command) {
|
|
34
|
+
case "/task":
|
|
35
|
+
return await this.taskResult(message);
|
|
31
36
|
case "/help":
|
|
32
37
|
case "/start":
|
|
33
38
|
return { handled: true, text: this.helpText() };
|
|
@@ -61,6 +66,9 @@ export class BotCommandHandler {
|
|
|
61
66
|
"/thinking - show thinking picker for this chat session",
|
|
62
67
|
"/thinking <off|low|medium|high> - set thinking level for this chat session",
|
|
63
68
|
"/thinking default - clear thinking override for this chat session",
|
|
69
|
+
"/task list - list scheduled tasks for this chat",
|
|
70
|
+
"/task create <intervalMs> <prompt> - create a scheduled task",
|
|
71
|
+
"/task remove <id> - remove a scheduled task",
|
|
64
72
|
"/agent <agentId> <message> - send message to specific agent",
|
|
65
73
|
"@agentId <message> - send message to specific agent",
|
|
66
74
|
].join("\n");
|
|
@@ -83,8 +91,8 @@ export class BotCommandHandler {
|
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
return [
|
|
86
|
-
|
|
87
|
-
|
|
94
|
+
"Default agent: " + (chat.defaultAgentId ?? "none"),
|
|
95
|
+
"Allowed agents: " + (allowedAgentIds.length > 0 ? allowedAgentIds.join(", ") : "none"),
|
|
88
96
|
].join("\n");
|
|
89
97
|
}
|
|
90
98
|
|
|
@@ -96,16 +104,18 @@ export class BotCommandHandler {
|
|
|
96
104
|
const value = args.join(" ").trim();
|
|
97
105
|
|
|
98
106
|
if (!value) {
|
|
107
|
+
const choices = await this.loadModelChoices();
|
|
108
|
+
|
|
99
109
|
return {
|
|
100
110
|
handled: true,
|
|
101
111
|
text: [
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
"Agent: " + sessionResult.agentId,
|
|
113
|
+
"Session model: " + (sessionResult.session.model ?? "default"),
|
|
114
|
+
choices.length > 0
|
|
105
115
|
? "Choose a model:"
|
|
106
|
-
: "No
|
|
116
|
+
: "No available Pi models found. Configure provider auth or use /model <provider/modelId> manually.",
|
|
107
117
|
].join("\n"),
|
|
108
|
-
inlineKeyboard:
|
|
118
|
+
inlineKeyboard: choices.length > 0 ? this.modelKeyboard(choices) : undefined,
|
|
109
119
|
};
|
|
110
120
|
}
|
|
111
121
|
|
|
@@ -118,7 +128,7 @@ export class BotCommandHandler {
|
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, value);
|
|
121
|
-
return { handled: true, text:
|
|
131
|
+
return { handled: true, text: "Session model for " + session.agentId + " set to " + session.model + "." };
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
private async thinkingResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
@@ -132,8 +142,8 @@ export class BotCommandHandler {
|
|
|
132
142
|
return {
|
|
133
143
|
handled: true,
|
|
134
144
|
text: [
|
|
135
|
-
|
|
136
|
-
|
|
145
|
+
"Agent: " + sessionResult.agentId,
|
|
146
|
+
"Session thinking level: " + (sessionResult.session.thinkingLevel ?? "default"),
|
|
137
147
|
"Choose thinking level:",
|
|
138
148
|
].join("\n"),
|
|
139
149
|
inlineKeyboard: this.thinkingKeyboard(),
|
|
@@ -149,21 +159,22 @@ export class BotCommandHandler {
|
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
|
|
152
|
-
return { handled: true, text:
|
|
162
|
+
return { handled: true, text: "Session thinking level for " + session.agentId + " set to " + session.thinkingLevel + "." };
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
private async setModelFromChoice(message: InboundMessage): Promise<string> {
|
|
156
166
|
const sessionResult = await this.getDefaultSession(message);
|
|
157
167
|
if (!sessionResult.ok) return sessionResult.message;
|
|
158
168
|
|
|
169
|
+
const choices = await this.loadModelChoices();
|
|
159
170
|
const choiceIndex = Number(message.text.slice("/model:set:".length));
|
|
160
|
-
if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >=
|
|
171
|
+
if (!Number.isSafeInteger(choiceIndex) || choiceIndex < 0 || choiceIndex >= choices.length) {
|
|
161
172
|
return "Unknown model choice.";
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
const choice =
|
|
175
|
+
const choice = choices[choiceIndex];
|
|
165
176
|
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, choice.id);
|
|
166
|
-
return
|
|
177
|
+
return "Session model for " + session.agentId + " set to " + choice.label + " (" + session.model + ").";
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
private async clearModel(message: InboundMessage): Promise<string> {
|
|
@@ -171,7 +182,7 @@ export class BotCommandHandler {
|
|
|
171
182
|
if (!sessionResult.ok) return sessionResult.message;
|
|
172
183
|
|
|
173
184
|
const session = await this.repositories.sessions.updateModel(sessionResult.session.id, null);
|
|
174
|
-
return
|
|
185
|
+
return "Session model cleared for " + session.agentId + ". Current: " + (session.model ?? "default");
|
|
175
186
|
}
|
|
176
187
|
|
|
177
188
|
private async setThinkingFromCallback(message: InboundMessage): Promise<string> {
|
|
@@ -184,7 +195,7 @@ export class BotCommandHandler {
|
|
|
184
195
|
}
|
|
185
196
|
|
|
186
197
|
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, value);
|
|
187
|
-
return
|
|
198
|
+
return "Session thinking level for " + session.agentId + " set to " + session.thinkingLevel + ".";
|
|
188
199
|
}
|
|
189
200
|
|
|
190
201
|
private async clearThinking(message: InboundMessage): Promise<string> {
|
|
@@ -192,16 +203,109 @@ export class BotCommandHandler {
|
|
|
192
203
|
if (!sessionResult.ok) return sessionResult.message;
|
|
193
204
|
|
|
194
205
|
const session = await this.repositories.sessions.updateThinkingLevel(sessionResult.session.id, null);
|
|
195
|
-
return
|
|
206
|
+
return "Session thinking level cleared for " + session.agentId + ". Current: " + (session.thinkingLevel ?? "default");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async taskResult(message: InboundMessage): Promise<BotCommandResult> {
|
|
210
|
+
if (!this.scheduler) {
|
|
211
|
+
return { handled: true, text: "Scheduler is not available." };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parts = message.text.trim().split(/\s+/);
|
|
215
|
+
const subcommand = parts[1];
|
|
216
|
+
|
|
217
|
+
if (subcommand === "list") {
|
|
218
|
+
return await this.taskList(message);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (subcommand === "create") {
|
|
222
|
+
return await this.taskCreate(message, parts);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (subcommand === "remove") {
|
|
226
|
+
return await this.taskRemove(parts);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
handled: true,
|
|
231
|
+
text: [
|
|
232
|
+
"Usage:",
|
|
233
|
+
"/task list - list scheduled tasks",
|
|
234
|
+
"/task create <intervalMs> <prompt> - create a task",
|
|
235
|
+
"/task remove <id> - remove a task",
|
|
236
|
+
].join("\n"),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private async taskList(message: InboundMessage): Promise<BotCommandResult> {
|
|
241
|
+
const tasks = this.scheduler!.getTasks().filter((t) => t.chatId === message.chatId || !t.chatId);
|
|
242
|
+
if (tasks.length === 0) return { handled: true, text: "No scheduled tasks for this chat." };
|
|
243
|
+
|
|
244
|
+
const lines = tasks.map((t) => {
|
|
245
|
+
const truncated = t.prompt.length > 50 ? t.prompt.slice(0, 50) + "..." : t.prompt;
|
|
246
|
+
return "- " + t.id + ": " + t.agent + " every " + t.intervalMs + 'ms "' + truncated + '"';
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return { handled: true, text: "Scheduled tasks:\n" + lines.join("\n") };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private async taskCreate(message: InboundMessage, parts: string[]): Promise<BotCommandResult> {
|
|
253
|
+
const intervalMs = Number(parts[2]);
|
|
254
|
+
if (!Number.isInteger(intervalMs) || intervalMs < 1000) {
|
|
255
|
+
return { handled: true, text: "Invalid interval. Use a number >= 1000 (milliseconds)." };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const prompt = parts.slice(3).join(" ").trim();
|
|
259
|
+
if (!prompt) {
|
|
260
|
+
return { handled: true, text: "Missing prompt. Usage: /task create <intervalMs> <prompt>" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const chat = await this.repositories.telegram.findChat(message.chatId);
|
|
264
|
+
if (!chat?.defaultAgentId) {
|
|
265
|
+
return { handled: true, text: "No default agent configured for this chat. Set one first." };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const saved = await this.scheduler!.addDynamicTask({
|
|
269
|
+
agent: chat.defaultAgentId,
|
|
270
|
+
intervalMs,
|
|
271
|
+
prompt,
|
|
272
|
+
channel: "telegram",
|
|
273
|
+
chatId: message.chatId,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const msg = "Task created: " + saved.id + "\n"
|
|
277
|
+
+ "Agent: " + saved.agent + "\n"
|
|
278
|
+
+ "Interval: " + saved.intervalMs + "ms\n"
|
|
279
|
+
+ "Prompt: " + saved.prompt;
|
|
280
|
+
|
|
281
|
+
return { handled: true, text: msg };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async taskRemove(parts: string[]): Promise<BotCommandResult> {
|
|
285
|
+
const taskId = parts[2];
|
|
286
|
+
if (!taskId) {
|
|
287
|
+
return { handled: true, text: "Missing task id. Usage: /task remove <id>" };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const removed = await this.scheduler!.removeDynamicTask(taskId);
|
|
291
|
+
if (!removed) return { handled: true, text: "Task not found: " + taskId };
|
|
292
|
+
|
|
293
|
+
return { handled: true, text: "Task removed: " + taskId };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private async loadModelChoices(): Promise<ModelChoiceConfig[]> {
|
|
297
|
+
if (this.modelChoices.length > 0) return this.modelChoices;
|
|
298
|
+
|
|
299
|
+
return getAvailableModelChoices();
|
|
196
300
|
}
|
|
197
301
|
|
|
198
|
-
private modelKeyboard(): InlineKeyboardButton[][] {
|
|
302
|
+
private modelKeyboard(choices: ModelChoiceConfig[]): InlineKeyboardButton[][] {
|
|
199
303
|
const rows = chunk(
|
|
200
|
-
|
|
304
|
+
choices.map((choice, index) => ({
|
|
201
305
|
text: choice.label,
|
|
202
|
-
callbackData:
|
|
306
|
+
callbackData: "model:set:" + index,
|
|
203
307
|
})),
|
|
204
|
-
|
|
308
|
+
1,
|
|
205
309
|
);
|
|
206
310
|
|
|
207
311
|
rows.push([{ text: "Use default", callbackData: "model:default" }]);
|
|
@@ -240,11 +344,11 @@ export class BotCommandHandler {
|
|
|
240
344
|
}
|
|
241
345
|
|
|
242
346
|
if (!this.registry.hasAgent(agentId)) {
|
|
243
|
-
return { ok: false as const, message:
|
|
347
|
+
return { ok: false as const, message: "Unknown agent: " + agentId };
|
|
244
348
|
}
|
|
245
349
|
|
|
246
350
|
if (!(await this.repositories.telegram.isAgentAllowed(message.chatId, agentId))) {
|
|
247
|
-
return { ok: false as const, message:
|
|
351
|
+
return { ok: false as const, message: "Agent is not allowed in this chat: " + agentId };
|
|
248
352
|
}
|
|
249
353
|
|
|
250
354
|
const session = await this.repositories.sessions.getOrCreate({
|
package/src/config/loadConfig.ts
CHANGED
package/src/config/schemas.ts
CHANGED
|
@@ -11,10 +11,18 @@ export const PermissionConfigSchema = z.object({
|
|
|
11
11
|
blockedTools: z.array(z.string()).default([]),
|
|
12
12
|
});
|
|
13
13
|
|
|
14
|
-
export const
|
|
15
|
-
|
|
14
|
+
export const TaskConfigSchema = z.object({
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
agent: z.string().min(1),
|
|
16
17
|
intervalMs: z.number().int().positive().default(600_000),
|
|
17
18
|
prompt: z.string().min(1).default("If no useful action is needed, reply exactly: NOOP."),
|
|
19
|
+
channel: z.string().default("telegram"),
|
|
20
|
+
chatId: z.string().optional(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const SchedulerConfigSchema = z.object({
|
|
24
|
+
tickIntervalMs: z.number().int().positive().default(10_000),
|
|
25
|
+
tasks: z.array(TaskConfigSchema).default([]),
|
|
18
26
|
});
|
|
19
27
|
|
|
20
28
|
export const AgentTelegramChannelConfigSchema = z.object({
|
|
@@ -36,11 +44,6 @@ export const AgentConfigSchema = z.object({
|
|
|
36
44
|
skills: z.array(z.string()).default([]),
|
|
37
45
|
extensions: z.array(z.string()).default([]),
|
|
38
46
|
channels: AgentChannelsConfigSchema.default({ telegram: { enabled: false } }),
|
|
39
|
-
heartbeat: HeartbeatConfigSchema.default({
|
|
40
|
-
enabled: false,
|
|
41
|
-
intervalMs: 600_000,
|
|
42
|
-
prompt: "If no useful action is needed, reply exactly: NOOP.",
|
|
43
|
-
}),
|
|
44
47
|
permissions: PermissionConfigSchema.default({
|
|
45
48
|
canRunShell: false,
|
|
46
49
|
canEditFiles: false,
|
|
@@ -82,10 +85,12 @@ export const TelegramChatConfigSchema = z.object({
|
|
|
82
85
|
export const RootConfigSchema = z.object({
|
|
83
86
|
telegramChats: z.array(TelegramChatConfigSchema).default([]),
|
|
84
87
|
modelChoices: z.array(ModelChoiceConfigSchema).default([]),
|
|
88
|
+
scheduler: SchedulerConfigSchema.default({ tickIntervalMs: 10_000, tasks: [] }),
|
|
85
89
|
});
|
|
86
90
|
|
|
87
91
|
export type PermissionConfig = z.infer<typeof PermissionConfigSchema>;
|
|
88
|
-
export type
|
|
92
|
+
export type TaskConfig = z.infer<typeof TaskConfigSchema>;
|
|
93
|
+
export type SchedulerConfig = z.infer<typeof SchedulerConfigSchema>;
|
|
89
94
|
export type AgentConfig = z.infer<typeof AgentConfigSchema>;
|
|
90
95
|
export type ProfileConfig = z.infer<typeof ProfileConfigSchema>;
|
|
91
96
|
export type TelegramChatConfig = z.infer<typeof TelegramChatConfigSchema>;
|
|
@@ -102,4 +107,5 @@ export type LoadedConfig = {
|
|
|
102
107
|
profiles: ProfileConfig[];
|
|
103
108
|
telegramChats: TelegramChatConfig[];
|
|
104
109
|
modelChoices: ModelChoiceConfig[];
|
|
110
|
+
scheduler: SchedulerConfig;
|
|
105
111
|
};
|
|
@@ -9,6 +9,7 @@ import { loadConfig } from "../config/loadConfig";
|
|
|
9
9
|
import type { LoadedConfig } from "../config/schemas";
|
|
10
10
|
import { createRepositories, type Repositories } from "../db/repositories";
|
|
11
11
|
import { logger } from "../logging/logger";
|
|
12
|
+
import { Scheduler } from "./Scheduler";
|
|
12
13
|
|
|
13
14
|
export class AgentDaemon {
|
|
14
15
|
private running = false;
|
|
@@ -17,6 +18,7 @@ export class AgentDaemon {
|
|
|
17
18
|
private readonly registry: AgentRegistry;
|
|
18
19
|
private readonly router: MessageRouter;
|
|
19
20
|
private readonly runner: AgentRunner;
|
|
21
|
+
private readonly scheduler: Scheduler;
|
|
20
22
|
|
|
21
23
|
private constructor(
|
|
22
24
|
private readonly config: LoadedConfig,
|
|
@@ -24,7 +26,8 @@ export class AgentDaemon {
|
|
|
24
26
|
) {
|
|
25
27
|
this.adapters = createAdapters(repositories);
|
|
26
28
|
this.registry = new AgentRegistry(config, repositories);
|
|
27
|
-
this.
|
|
29
|
+
this.scheduler = new Scheduler(config.scheduler, this.registry, repositories, this.adapters);
|
|
30
|
+
this.commands = new BotCommandHandler(this.registry, repositories, config.modelChoices, this.scheduler);
|
|
28
31
|
this.router = new MessageRouter(repositories, this.registry);
|
|
29
32
|
this.runner = new AgentRunner(this.registry, repositories);
|
|
30
33
|
}
|
|
@@ -45,6 +48,8 @@ export class AgentDaemon {
|
|
|
45
48
|
await adapter.start((message) => this.handleInboundMessage(message));
|
|
46
49
|
}
|
|
47
50
|
|
|
51
|
+
await this.scheduler.start();
|
|
52
|
+
|
|
48
53
|
logger.info("pigent daemon started", {
|
|
49
54
|
agents: this.registry.listAgents().length,
|
|
50
55
|
profiles: this.registry.listProfiles().length,
|
|
@@ -58,6 +63,8 @@ export class AgentDaemon {
|
|
|
58
63
|
|
|
59
64
|
this.running = false;
|
|
60
65
|
|
|
66
|
+
this.scheduler.stop();
|
|
67
|
+
|
|
61
68
|
for (const adapter of this.adapters) {
|
|
62
69
|
await adapter.stop();
|
|
63
70
|
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { SchedulerConfig, TaskConfig } from "../config/schemas";
|
|
2
|
+
import type { Repositories } from "../db/repositories";
|
|
3
|
+
import type { TaskConfigRow } from "../db/schema";
|
|
4
|
+
import { logger } from "../logging/logger";
|
|
5
|
+
import { PiAgentRunner } from "../pi/PiAgentRunner";
|
|
6
|
+
import type { AgentRegistry } from "../agents/AgentRegistry";
|
|
7
|
+
import type { ChannelAdapter } from "../channels/types";
|
|
8
|
+
import { isTaskDue } from "./taskDue";
|
|
9
|
+
|
|
10
|
+
export class Scheduler {
|
|
11
|
+
private running = false;
|
|
12
|
+
private tickTimer: ReturnType<typeof setTimeout> | null = null;
|
|
13
|
+
private readonly taskLocks = new Map<string, boolean>();
|
|
14
|
+
private readonly piRunner = new PiAgentRunner();
|
|
15
|
+
private tasks: TaskConfig[] = [];
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private readonly schedulerConfig: SchedulerConfig,
|
|
19
|
+
private readonly registry: AgentRegistry,
|
|
20
|
+
private readonly repositories: Repositories,
|
|
21
|
+
private readonly adapters: ChannelAdapter[],
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async start(): Promise<void> {
|
|
25
|
+
if (this.running) return;
|
|
26
|
+
|
|
27
|
+
this.tasks = await this.loadAllTasks();
|
|
28
|
+
|
|
29
|
+
if (this.tasks.length === 0) {
|
|
30
|
+
logger.info("scheduler skipped — no tasks configured");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this.running = true;
|
|
35
|
+
logger.info("scheduler started", {
|
|
36
|
+
tickIntervalMs: this.schedulerConfig.tickIntervalMs,
|
|
37
|
+
taskCount: this.tasks.length,
|
|
38
|
+
});
|
|
39
|
+
this.tick();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
stop(): void {
|
|
43
|
+
if (!this.running) return;
|
|
44
|
+
this.running = false;
|
|
45
|
+
if (this.tickTimer !== null) {
|
|
46
|
+
clearTimeout(this.tickTimer);
|
|
47
|
+
this.tickTimer = null;
|
|
48
|
+
}
|
|
49
|
+
logger.info("scheduler stopped");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getTasks(): TaskConfig[] {
|
|
53
|
+
return [...this.tasks];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Add a runtime-created task (from Telegram). Saves to DB, hot-adds to scheduler. */
|
|
57
|
+
async addDynamicTask(input: {
|
|
58
|
+
agent: string;
|
|
59
|
+
intervalMs: number;
|
|
60
|
+
prompt: string;
|
|
61
|
+
channel: string;
|
|
62
|
+
chatId: string;
|
|
63
|
+
}): Promise<TaskConfigRow> {
|
|
64
|
+
const saved = await this.repositories.taskConfigs.create(input);
|
|
65
|
+
|
|
66
|
+
this.tasks.push({
|
|
67
|
+
id: saved.id,
|
|
68
|
+
agent: saved.agent,
|
|
69
|
+
intervalMs: saved.intervalMs,
|
|
70
|
+
prompt: saved.prompt,
|
|
71
|
+
channel: saved.channel,
|
|
72
|
+
chatId: saved.chatId,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!this.running && this.tasks.length === 1) {
|
|
76
|
+
// First task added, start scheduler
|
|
77
|
+
this.running = true;
|
|
78
|
+
this.tick();
|
|
79
|
+
logger.info("scheduler started on first dynamic task");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return saved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Remove a runtime task. Deletes from DB, hot-removes from scheduler. */
|
|
86
|
+
async removeDynamicTask(id: string): Promise<boolean> {
|
|
87
|
+
const before = this.tasks.length;
|
|
88
|
+
this.tasks = this.tasks.filter((t) => t.id !== id);
|
|
89
|
+
await this.repositories.taskConfigs.remove(id);
|
|
90
|
+
|
|
91
|
+
const removed = this.tasks.length < before;
|
|
92
|
+
|
|
93
|
+
if (removed && this.tasks.length === 0 && this.running) {
|
|
94
|
+
this.stop();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return removed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private async loadAllTasks(): Promise<TaskConfig[]> {
|
|
101
|
+
const configTasks: TaskConfig[] = this.schedulerConfig.tasks;
|
|
102
|
+
const dbTasks = await this.repositories.taskConfigs.findAllEnabled();
|
|
103
|
+
|
|
104
|
+
const dbTaskConfigs: TaskConfig[] = dbTasks.map((row) => ({
|
|
105
|
+
id: row.id,
|
|
106
|
+
agent: row.agent,
|
|
107
|
+
intervalMs: row.intervalMs,
|
|
108
|
+
prompt: row.prompt,
|
|
109
|
+
channel: row.channel,
|
|
110
|
+
chatId: row.chatId,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
return [...configTasks, ...dbTaskConfigs];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private tick(): void {
|
|
117
|
+
if (!this.running) return;
|
|
118
|
+
|
|
119
|
+
for (const task of this.tasks) {
|
|
120
|
+
this.runTaskIfDue(task);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.tickTimer = setTimeout(() => this.tick(), this.schedulerConfig.tickIntervalMs);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async runTaskIfDue(task: TaskConfig): Promise<void> {
|
|
127
|
+
const lockKey = task.id;
|
|
128
|
+
if (this.taskLocks.get(lockKey)) return;
|
|
129
|
+
|
|
130
|
+
const lastRun = await this.repositories.taskRuns.findLatest(task.agent, task.id);
|
|
131
|
+
if (!isTaskDue(lastRun?.createdAt ?? null, task.intervalMs)) return;
|
|
132
|
+
|
|
133
|
+
const agent = this.registry.getAgent(task.agent);
|
|
134
|
+
if (!agent) {
|
|
135
|
+
logger.warn("task references unknown agent, skipping", { taskId: task.id, agent: task.agent });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.taskLocks.set(lockKey, true);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
await this.executeTask(task, agent);
|
|
143
|
+
} finally {
|
|
144
|
+
this.taskLocks.set(lockKey, false);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private async executeTask(task: TaskConfig, agent: any): Promise<void> {
|
|
149
|
+
logger.info("task executing", { taskId: task.id, agentId: task.agent });
|
|
150
|
+
|
|
151
|
+
const run = await this.repositories.taskRuns.create({
|
|
152
|
+
agentId: task.agent,
|
|
153
|
+
taskId: task.id,
|
|
154
|
+
prompt: task.prompt,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await this.repositories.taskRuns.markRunning(run.id);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const session = await this.repositories.sessions.getOrCreate({
|
|
161
|
+
agentId: task.agent,
|
|
162
|
+
channel: task.channel,
|
|
163
|
+
chatId: task.chatId ?? `task:${task.id}`,
|
|
164
|
+
threadId: null,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const fullPrompt = this.composeTaskPrompt(task);
|
|
168
|
+
const profile = this.registry.getProfile(agent.profile);
|
|
169
|
+
|
|
170
|
+
const response = await this.piRunner.run({
|
|
171
|
+
agent,
|
|
172
|
+
profile,
|
|
173
|
+
session,
|
|
174
|
+
prompt: fullPrompt,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const trimmed = response.trim();
|
|
178
|
+
|
|
179
|
+
if (trimmed.toUpperCase() === "NOOP") {
|
|
180
|
+
await this.repositories.taskRuns.markNoop(run.id, { result: response });
|
|
181
|
+
logger.info("task noop", { taskId: task.id });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await this.repositories.taskRuns.markNotified(run.id, { result: response });
|
|
186
|
+
await this.sendToChannel(task, agent, response);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
189
|
+
logger.error("task failed", { taskId: task.id, error: errorMessage });
|
|
190
|
+
await this.repositories.taskRuns.markFailed(run.id, { error: errorMessage });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private async sendToChannel(task: TaskConfig, agent: any, text: string): Promise<void> {
|
|
195
|
+
const adapter = this.adapters.find((a) => a.id === task.channel);
|
|
196
|
+
if (!adapter) {
|
|
197
|
+
logger.warn("no adapter for task output", { taskId: task.id, channel: task.channel });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!task.chatId) {
|
|
202
|
+
logger.warn("no chatId for task output", { taskId: task.id });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
await adapter.send({
|
|
207
|
+
channel: task.channel as any,
|
|
208
|
+
chatId: task.chatId,
|
|
209
|
+
text: `[${agent.name} / ${task.id}]\n${text}`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private composeTaskPrompt(task: TaskConfig): string {
|
|
214
|
+
return [
|
|
215
|
+
`[Task: ${task.id}]`,
|
|
216
|
+
"",
|
|
217
|
+
task.prompt,
|
|
218
|
+
"",
|
|
219
|
+
"You are running as a scheduled task inside Pigent.",
|
|
220
|
+
"If no useful action is needed, reply exactly: NOOP.",
|
|
221
|
+
"Otherwise, reply with the result of your task.",
|
|
222
|
+
].join("\n");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { nanoid } from "nanoid";
|
|
3
|
+
import type { DbClient } from "../client";
|
|
4
|
+
import { taskConfigs, type TaskConfigRow } from "../schema";
|
|
5
|
+
|
|
6
|
+
export type CreateTaskConfigInput = {
|
|
7
|
+
agent: string;
|
|
8
|
+
intervalMs: number;
|
|
9
|
+
prompt: string;
|
|
10
|
+
channel: string;
|
|
11
|
+
chatId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export class TaskConfigRepository {
|
|
15
|
+
constructor(private readonly db: DbClient) {}
|
|
16
|
+
|
|
17
|
+
async create(input: CreateTaskConfigInput): Promise<TaskConfigRow> {
|
|
18
|
+
const id = nanoid(8);
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
|
|
21
|
+
await this.db.insert(taskConfigs).values({
|
|
22
|
+
id,
|
|
23
|
+
agent: input.agent,
|
|
24
|
+
intervalMs: input.intervalMs,
|
|
25
|
+
prompt: input.prompt,
|
|
26
|
+
channel: input.channel,
|
|
27
|
+
chatId: input.chatId,
|
|
28
|
+
enabled: true,
|
|
29
|
+
createdAt: now,
|
|
30
|
+
updatedAt: now,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const row = await this.findById(id);
|
|
34
|
+
if (!row) throw new Error(`failed to create task config ${id}`);
|
|
35
|
+
|
|
36
|
+
return row;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async remove(id: string): Promise<void> {
|
|
40
|
+
await this.db.delete(taskConfigs).where(eq(taskConfigs.id, id));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findById(id: string): Promise<TaskConfigRow | null> {
|
|
44
|
+
const row = await this.db.query.taskConfigs.findFirst({
|
|
45
|
+
where: eq(taskConfigs.id, id),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return row ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async findAllByChatId(chatId: string): Promise<TaskConfigRow[]> {
|
|
52
|
+
return this.db.query.taskConfigs.findMany({
|
|
53
|
+
where: eq(taskConfigs.chatId, chatId),
|
|
54
|
+
orderBy: (fields, { asc }) => [asc(fields.createdAt)],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async findAllEnabled(): Promise<TaskConfigRow[]> {
|
|
59
|
+
return this.db.query.taskConfigs.findMany({
|
|
60
|
+
where: eq(taskConfigs.enabled, true),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|