macroclaw 0.26.0 → 0.27.0
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/package.json +1 -1
- package/src/app.test.ts +27 -65
- package/src/app.ts +13 -13
- package/src/orchestrator.test.ts +246 -331
- package/src/orchestrator.ts +141 -153
- package/src/prompts.test.ts +1 -1
- package/src/prompts.ts +10 -16
package/src/orchestrator.ts
CHANGED
|
@@ -3,13 +3,12 @@ import {
|
|
|
3
3
|
Claude,
|
|
4
4
|
QueryParseError,
|
|
5
5
|
QueryProcessError,
|
|
6
|
-
type QueryResult,
|
|
7
6
|
QueryValidationError,
|
|
8
7
|
type RunningQuery
|
|
9
8
|
} from "./claude";
|
|
10
9
|
import { writeHistoryPrompt, writeHistoryResult } from "./history";
|
|
11
10
|
import { createLogger } from "./logger";
|
|
12
|
-
import {
|
|
11
|
+
import { SYSTEM_PROMPT } from "./prompts";
|
|
13
12
|
import { Queue } from "./queue";
|
|
14
13
|
import { loadSessions, saveSessions } from "./sessions";
|
|
15
14
|
|
|
@@ -17,6 +16,10 @@ type ButtonSpec = string | { text: string; data: string };
|
|
|
17
16
|
|
|
18
17
|
const log = createLogger("orchestrator");
|
|
19
18
|
|
|
19
|
+
// --- Constants ---
|
|
20
|
+
|
|
21
|
+
const WAIT_THRESHOLD = 60_000;
|
|
22
|
+
|
|
20
23
|
// --- Response schema ---
|
|
21
24
|
|
|
22
25
|
const backgroundAgentSchema = z.object({
|
|
@@ -56,20 +59,21 @@ export interface OrchestratorResponse {
|
|
|
56
59
|
type OrchestratorRequest =
|
|
57
60
|
| { type: "user"; message: string; files?: string[] }
|
|
58
61
|
| { type: "cron"; name: string; prompt: string; model?: string }
|
|
59
|
-
| { type: "background-agent-result"; name: string; response: AgentOutput
|
|
62
|
+
| { type: "background-agent-result"; name: string; response: AgentOutput }
|
|
60
63
|
| { type: "button"; label: string };
|
|
61
64
|
|
|
62
65
|
function escapeHtml(text: string): string {
|
|
63
66
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
64
67
|
}
|
|
65
68
|
|
|
66
|
-
// ---
|
|
69
|
+
// --- Session tracking ---
|
|
67
70
|
|
|
68
|
-
interface
|
|
71
|
+
interface SessionInfo {
|
|
69
72
|
name: string;
|
|
70
73
|
prompt: string;
|
|
71
74
|
model?: string;
|
|
72
75
|
query: RunningQuery<AgentOutput>;
|
|
76
|
+
lastMessageAt: Date;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export interface OrchestratorConfig {
|
|
@@ -78,19 +82,23 @@ export interface OrchestratorConfig {
|
|
|
78
82
|
settingsDir?: string;
|
|
79
83
|
onResponse: (response: OrchestratorResponse) => Promise<void>;
|
|
80
84
|
claude?: Claude;
|
|
85
|
+
/** How long to wait for a running main session before demoting it (ms). Default: 60000 */
|
|
86
|
+
waitThreshold?: number;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
export class Orchestrator {
|
|
84
90
|
#config: Omit<OrchestratorConfig , 'claude'>;
|
|
85
91
|
#claude: Claude;
|
|
92
|
+
#waitThreshold: number;
|
|
86
93
|
|
|
87
94
|
#mainSessionId: string | undefined;
|
|
88
|
-
#
|
|
95
|
+
#runningSessions = new Map<string, SessionInfo>();
|
|
89
96
|
#queue: Queue<OrchestratorRequest>;
|
|
90
97
|
|
|
91
98
|
constructor(config: OrchestratorConfig) {
|
|
92
99
|
this.#config = config;
|
|
93
100
|
this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: SYSTEM_PROMPT });
|
|
101
|
+
this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
|
|
94
102
|
this.#queue = new Queue<OrchestratorRequest>();
|
|
95
103
|
this.#queue.setHandler((request) => this.#handleRequest(request));
|
|
96
104
|
|
|
@@ -108,7 +116,9 @@ export class Orchestrator {
|
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
handleCron(name: string, prompt: string, model?: string): void {
|
|
111
|
-
|
|
119
|
+
const cronName = `cron-${name}`;
|
|
120
|
+
const cronPrompt = `[Context: cron/${name}] ${prompt}`;
|
|
121
|
+
this.#spawnBackground(cronName, cronPrompt, model ?? this.#config.model);
|
|
112
122
|
}
|
|
113
123
|
|
|
114
124
|
handleBackgroundCommand(prompt: string): void {
|
|
@@ -117,38 +127,42 @@ export class Orchestrator {
|
|
|
117
127
|
this.#callOnResponse({ message: `Background agent "${escapeHtml(name)}" started.` });
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
this.#callOnResponse({ message: "No
|
|
130
|
+
handleSessions(): void {
|
|
131
|
+
const sessions = [...this.#runningSessions.entries()];
|
|
132
|
+
if (sessions.length === 0) {
|
|
133
|
+
this.#callOnResponse({ message: "No running sessions." });
|
|
124
134
|
return;
|
|
125
135
|
}
|
|
126
|
-
const lines =
|
|
127
|
-
const elapsed = Math.round((Date.now() -
|
|
128
|
-
|
|
136
|
+
const lines = sessions.map(([sid, s]) => {
|
|
137
|
+
const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
|
|
138
|
+
const isMain = sid === this.#mainSessionId;
|
|
139
|
+
return isMain
|
|
140
|
+
? `▶ ${escapeHtml(s.name)} (${elapsed}s) [main]`
|
|
141
|
+
: `- ${escapeHtml(s.name)} (${elapsed}s)`;
|
|
129
142
|
});
|
|
130
|
-
const buttons: ButtonSpec[] =
|
|
131
|
-
const elapsed = Math.round((Date.now() -
|
|
132
|
-
const text = `${
|
|
133
|
-
return { text, data: `detail:${
|
|
143
|
+
const buttons: ButtonSpec[] = sessions.map(([sid, s]) => {
|
|
144
|
+
const elapsed = Math.round((Date.now() - s.query.startedAt.getTime()) / 1000);
|
|
145
|
+
const text = `${s.name} (${elapsed}s)`.slice(0, 27);
|
|
146
|
+
return { text, data: `detail:${sid}` };
|
|
134
147
|
});
|
|
135
|
-
buttons.push({text: "Dismiss", data: "_dismiss"});
|
|
148
|
+
buttons.push({ text: "Dismiss", data: "_dismiss" });
|
|
136
149
|
this.#callOnResponse({ message: lines.join("\n"), buttons });
|
|
137
150
|
}
|
|
138
151
|
|
|
139
152
|
handleDetail(sessionId: string): void {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
142
|
-
this.#callOnResponse({ message: "
|
|
153
|
+
const session = this.#runningSessions.get(sessionId);
|
|
154
|
+
if (!session) {
|
|
155
|
+
this.#callOnResponse({ message: "Session not found or already finished." });
|
|
143
156
|
return;
|
|
144
157
|
}
|
|
145
158
|
|
|
146
|
-
const elapsed = Math.round((Date.now() -
|
|
147
|
-
const truncatedPrompt =
|
|
159
|
+
const elapsed = Math.round((Date.now() - session.query.startedAt.getTime()) / 1000);
|
|
160
|
+
const truncatedPrompt = session.prompt.length > 300 ? `${session.prompt.slice(0, 300)}…` : session.prompt;
|
|
161
|
+
const isMain = sessionId === this.#mainSessionId;
|
|
148
162
|
const lines = [
|
|
149
|
-
`<b>${escapeHtml(
|
|
163
|
+
`<b>${escapeHtml(session.name)}</b>${isMain ? " [main]" : ""}`,
|
|
150
164
|
`Prompt: ${escapeHtml(truncatedPrompt)}`,
|
|
151
|
-
`Model: ${
|
|
165
|
+
`Model: ${session.model ?? "default"}`,
|
|
152
166
|
`Elapsed: ${elapsed}s`,
|
|
153
167
|
"Status: running",
|
|
154
168
|
];
|
|
@@ -161,16 +175,16 @@ export class Orchestrator {
|
|
|
161
175
|
}
|
|
162
176
|
|
|
163
177
|
async handlePeek(sessionId: string): Promise<void> {
|
|
164
|
-
const
|
|
165
|
-
if (!
|
|
166
|
-
this.#callOnResponse({ message: "
|
|
178
|
+
const session = this.#runningSessions.get(sessionId);
|
|
179
|
+
if (!session) {
|
|
180
|
+
this.#callOnResponse({ message: "Session not found or already finished." });
|
|
167
181
|
return;
|
|
168
182
|
}
|
|
169
183
|
|
|
170
|
-
this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(
|
|
184
|
+
this.#callOnResponse({ message: `Peeking at <b>${escapeHtml(session.name)}</b>...` });
|
|
171
185
|
|
|
172
186
|
try {
|
|
173
|
-
const startedAt =
|
|
187
|
+
const startedAt = session.query.startedAt.toISOString();
|
|
174
188
|
const query = this.#claude.forkSession(
|
|
175
189
|
sessionId,
|
|
176
190
|
`This session started at ${startedAt}. Only consider events after that time. Give a brief status update: what has been done so far, what's currently happening, and what's remaining. 2-3 sentences max.`,
|
|
@@ -178,65 +192,76 @@ export class Orchestrator {
|
|
|
178
192
|
{ model: "haiku" },
|
|
179
193
|
);
|
|
180
194
|
const { value } = await query.result;
|
|
181
|
-
this.#callOnResponse({ message: `<b>[${escapeHtml(
|
|
195
|
+
this.#callOnResponse({ message: `<b>[${escapeHtml(session.name)}]</b> ${value || "[No output]"}` });
|
|
182
196
|
} catch (err) {
|
|
183
|
-
this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(
|
|
197
|
+
this.#callOnResponse({ message: `Couldn't peek at ${escapeHtml(session.name)}: ${err}` });
|
|
184
198
|
}
|
|
185
199
|
}
|
|
186
200
|
|
|
187
201
|
async handleKill(sessionId: string): Promise<void> {
|
|
188
|
-
const
|
|
189
|
-
if (!
|
|
190
|
-
this.#callOnResponse({ message: "
|
|
202
|
+
const session = this.#runningSessions.get(sessionId);
|
|
203
|
+
if (!session) {
|
|
204
|
+
this.#callOnResponse({ message: "Session not found or already finished." });
|
|
191
205
|
return;
|
|
192
206
|
}
|
|
193
207
|
|
|
194
|
-
this.#
|
|
208
|
+
this.#runningSessions.delete(sessionId);
|
|
195
209
|
|
|
196
210
|
try {
|
|
197
|
-
await
|
|
211
|
+
await session.query.kill();
|
|
198
212
|
} catch (err) {
|
|
199
|
-
log.error({ err, name:
|
|
213
|
+
log.error({ err, name: session.name }, "Kill failed");
|
|
200
214
|
}
|
|
201
215
|
|
|
202
|
-
this.#callOnResponse({ message: `Killed <b>${escapeHtml(
|
|
216
|
+
this.#callOnResponse({ message: `Killed <b>${escapeHtml(session.name)}</b>.` });
|
|
203
217
|
}
|
|
204
218
|
|
|
205
|
-
handleSessionCommand(): void {
|
|
206
|
-
this.#callOnResponse({ message: `Session: <code>${this.#mainSessionId ?? "none"}</code>` });
|
|
207
|
-
}
|
|
208
219
|
|
|
209
220
|
// --- Internal queue handler ---
|
|
210
221
|
|
|
211
222
|
async #handleRequest(request: OrchestratorRequest): Promise<void> {
|
|
212
223
|
log.debug({ type: request.type }, "Incoming request");
|
|
213
224
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
225
|
+
const mainInfo = this.#mainSessionId ? this.#runningSessions.get(this.#mainSessionId) : undefined;
|
|
226
|
+
let movedToBackground: string | undefined;
|
|
227
|
+
|
|
228
|
+
if (mainInfo) {
|
|
229
|
+
const elapsed = Date.now() - mainInfo.lastMessageAt.getTime();
|
|
230
|
+
if (elapsed >= this.#waitThreshold) {
|
|
231
|
+
// Main has been running too long — move to background immediately
|
|
232
|
+
log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (exceeded threshold)");
|
|
233
|
+
movedToBackground = mainInfo.prompt;
|
|
234
|
+
} else {
|
|
235
|
+
// Main started recently — wait for it to finish or threshold
|
|
236
|
+
const remaining = this.#waitThreshold - elapsed;
|
|
237
|
+
const finished = await Promise.race([
|
|
238
|
+
mainInfo.query.result.then(() => true as const, () => true as const),
|
|
239
|
+
new Promise<false>((r) => setTimeout(() => r(false), remaining)),
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
if (!finished) {
|
|
243
|
+
log.info({ name: mainInfo.name, sessionId: mainInfo.query.sessionId }, "Moving main session to background (wait timed out)");
|
|
244
|
+
movedToBackground = mainInfo.prompt;
|
|
245
|
+
}
|
|
246
|
+
// If finished: completion handler already delivered the result and removed from map.
|
|
247
|
+
}
|
|
219
248
|
}
|
|
220
249
|
|
|
221
250
|
await writeHistoryPrompt(request);
|
|
222
251
|
|
|
223
|
-
|
|
224
|
-
if (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
if (result.sessionId !== this.#mainSessionId) {
|
|
228
|
-
log.info({ oldSessionId: this.#mainSessionId, newSessionId: result.sessionId }, "Session updated");
|
|
229
|
-
this.#mainSessionId = result.sessionId;
|
|
230
|
-
saveSessions({ mainSessionId: this.#mainSessionId }, this.#config.settingsDir);
|
|
252
|
+
let prompt = this.#formatPrompt(request);
|
|
253
|
+
if (movedToBackground) {
|
|
254
|
+
const truncated = movedToBackground.length > 100 ? `${movedToBackground.slice(0, 100)}...` : movedToBackground;
|
|
255
|
+
prompt = `[Context: previous task "${truncated}" moved to background]\n${prompt}`;
|
|
231
256
|
}
|
|
232
257
|
|
|
233
|
-
|
|
234
|
-
await this.#deliverResponse(result.value);
|
|
258
|
+
this.#startMainQuery(prompt, this.#config.model);
|
|
235
259
|
}
|
|
236
260
|
|
|
237
261
|
// --- Response delivery ---
|
|
238
262
|
|
|
239
263
|
async #deliverResponse(response: AgentOutput): Promise<void> {
|
|
264
|
+
await writeHistoryResult(response);
|
|
240
265
|
if (response.action === "send") {
|
|
241
266
|
this.#callOnResponse({
|
|
242
267
|
message: response.message || "[No output]",
|
|
@@ -262,53 +287,59 @@ export class Orchestrator {
|
|
|
262
287
|
});
|
|
263
288
|
}
|
|
264
289
|
|
|
265
|
-
// ---
|
|
266
|
-
|
|
267
|
-
async #queryWithRetry(request: OrchestratorRequest): Promise<QueryResult<AgentOutput> | null> {
|
|
268
|
-
const timeout = request.type === "cron" ? CRON_TIMEOUT : MAIN_TIMEOUT;
|
|
269
|
-
const query = this.#query(request);
|
|
290
|
+
// --- Main session query ---
|
|
270
291
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return result;
|
|
275
|
-
} catch (err) {
|
|
276
|
-
// Resume failed — retry with a fresh session
|
|
277
|
-
if (err instanceof QueryProcessError && this.#mainSessionId) {
|
|
278
|
-
log.info("Resume failed, retrying with new session");
|
|
279
|
-
this.#mainSessionId = undefined;
|
|
280
|
-
const retryQuery = this.#query(request);
|
|
281
|
-
|
|
282
|
-
try {
|
|
283
|
-
const retryResult = await this.#awaitOrBackground(retryQuery, request, timeout);
|
|
284
|
-
if (!retryResult) return null;
|
|
285
|
-
return retryResult;
|
|
286
|
-
} catch (retryErr) {
|
|
287
|
-
return { value: this.#errorResponse(retryErr), sessionId: retryQuery.sessionId };
|
|
288
|
-
}
|
|
289
|
-
}
|
|
292
|
+
#startMainQuery(prompt: string, model: string | undefined): void {
|
|
293
|
+
const opts = { model };
|
|
294
|
+
let query: RunningQuery<AgentOutput>;
|
|
290
295
|
|
|
291
|
-
|
|
296
|
+
if (this.#mainSessionId && this.#runningSessions.has(this.#mainSessionId)) {
|
|
297
|
+
query = this.#claude.forkSession(this.#mainSessionId, prompt, responseResultType, opts);
|
|
298
|
+
} else if (this.#mainSessionId) {
|
|
299
|
+
query = this.#claude.resumeSession(this.#mainSessionId, prompt, responseResultType, opts);
|
|
300
|
+
} else {
|
|
301
|
+
query = this.#claude.newSession(prompt, responseResultType, opts);
|
|
292
302
|
}
|
|
293
|
-
}
|
|
294
303
|
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
const opts = { model };
|
|
304
|
+
const sid = query.sessionId;
|
|
305
|
+
const name = prompt.slice(0, 30).replace(/\s+/g, "-");
|
|
306
|
+
this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
|
|
299
307
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
308
|
+
if (sid !== this.#mainSessionId) {
|
|
309
|
+
log.info({ oldSessionId: this.#mainSessionId, newSessionId: sid }, "Session updated");
|
|
310
|
+
this.#mainSessionId = sid;
|
|
311
|
+
saveSessions({ mainSessionId: sid }, this.#config.settingsDir);
|
|
303
312
|
}
|
|
304
313
|
|
|
305
|
-
|
|
306
|
-
if (this.#mainSessionId) {
|
|
307
|
-
return this.#claude.resumeSession(this.#mainSessionId, prompt, responseResultType, opts);
|
|
308
|
-
}
|
|
314
|
+
log.debug({ name, sessionId: sid }, "Main query started");
|
|
309
315
|
|
|
310
|
-
|
|
311
|
-
|
|
316
|
+
query.result.then(
|
|
317
|
+
async ({ value: response }) => {
|
|
318
|
+
if (!this.#runningSessions.has(sid)) {
|
|
319
|
+
log.error({ name, sessionId: sid }, "Completed session not in runningSessions — delivering anyway");
|
|
320
|
+
await this.#deliverResponse(response);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.#runningSessions.delete(sid);
|
|
324
|
+
|
|
325
|
+
if (sid === this.#mainSessionId) {
|
|
326
|
+
log.debug({ name, sessionId: sid }, "Main query finished, delivering directly");
|
|
327
|
+
await this.#deliverResponse(response);
|
|
328
|
+
} else {
|
|
329
|
+
log.debug({ name, sessionId: sid }, "Non-main query finished, feeding to main session");
|
|
330
|
+
this.#queue.push({ type: "background-agent-result", name, response });
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
async (err) => {
|
|
334
|
+
if (!this.#runningSessions.has(sid)) {
|
|
335
|
+
log.error({ name, sessionId: sid, err }, "Failed session not in runningSessions — delivering error");
|
|
336
|
+
} else {
|
|
337
|
+
this.#runningSessions.delete(sid);
|
|
338
|
+
log.error({ name, sessionId: sid, err }, "Main query failed");
|
|
339
|
+
}
|
|
340
|
+
await this.#deliverResponse(this.#errorResponse(err));
|
|
341
|
+
},
|
|
342
|
+
);
|
|
312
343
|
}
|
|
313
344
|
|
|
314
345
|
#formatPrompt(request: OrchestratorRequest): string {
|
|
@@ -327,29 +358,6 @@ export class Orchestrator {
|
|
|
327
358
|
}
|
|
328
359
|
}
|
|
329
360
|
|
|
330
|
-
async #awaitOrBackground(
|
|
331
|
-
query: RunningQuery<AgentOutput>,
|
|
332
|
-
request: OrchestratorRequest,
|
|
333
|
-
timeoutMs: number,
|
|
334
|
-
): Promise<QueryResult<AgentOutput> | null> {
|
|
335
|
-
const result = await Promise.race([
|
|
336
|
-
query.result,
|
|
337
|
-
new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
|
338
|
-
]);
|
|
339
|
-
|
|
340
|
-
if (result !== null) return result;
|
|
341
|
-
|
|
342
|
-
const name = request.type === "user" ? request.message.slice(0, 30).replace(/\s+/g, "-")
|
|
343
|
-
: request.type === "cron" ? `cron-${request.name}`
|
|
344
|
-
: "task";
|
|
345
|
-
const prompt = this.#formatPrompt(request);
|
|
346
|
-
const model = request.type === "cron" ? (request.model ?? this.#config.model) : this.#config.model;
|
|
347
|
-
log.info({ name, sessionId: query.sessionId }, "Request backgrounded due to timeout");
|
|
348
|
-
this.#callOnResponse({ message: "This is taking longer, continuing in the background." });
|
|
349
|
-
this.#adoptBackground(name, prompt, model, query);
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
361
|
#errorResponse(err: unknown): AgentOutput {
|
|
354
362
|
if (err instanceof QueryProcessError) {
|
|
355
363
|
return { action: "send", message: `[Error] Claude exited with code ${err.exitCode}:\n${err.stderr}`, actionReason: "process-error" };
|
|
@@ -372,47 +380,27 @@ export class Orchestrator {
|
|
|
372
380
|
const query = this.#mainSessionId
|
|
373
381
|
? this.#claude.forkSession(this.#mainSessionId, bgPrompt, responseResultType, { model })
|
|
374
382
|
: this.#claude.newSession(bgPrompt, responseResultType, { model });
|
|
375
|
-
|
|
376
|
-
const info: BackgroundInfo = { name, prompt, model, query };
|
|
377
|
-
this.#backgroundAgents.set(sessionId, info);
|
|
378
|
-
|
|
379
|
-
log.debug({ name, sessionId }, "Starting background agent");
|
|
380
|
-
|
|
381
|
-
query.result.then(
|
|
382
|
-
async ({ value: response }) => {
|
|
383
|
-
if (!this.#backgroundAgents.has(sessionId)) return;
|
|
384
|
-
this.#backgroundAgents.delete(sessionId);
|
|
385
|
-
log.debug({ name, message: response.message }, "Background agent finished");
|
|
386
|
-
this.#queue.push({ type: "background-agent-result", name, response });
|
|
387
|
-
},
|
|
388
|
-
(err) => {
|
|
389
|
-
if (!this.#backgroundAgents.has(sessionId)) return;
|
|
390
|
-
this.#backgroundAgents.delete(sessionId);
|
|
391
|
-
log.error({ name, err }, "Background agent failed");
|
|
392
|
-
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-agent-failed" } });
|
|
393
|
-
},
|
|
394
|
-
);
|
|
383
|
+
this.#registerBackground(name, prompt, model, query);
|
|
395
384
|
}
|
|
396
385
|
|
|
397
|
-
#
|
|
398
|
-
const
|
|
399
|
-
|
|
400
|
-
this.#backgroundAgents.set(sessionId, info);
|
|
386
|
+
#registerBackground(name: string, prompt: string, model: string | undefined, query: RunningQuery<AgentOutput>) {
|
|
387
|
+
const sid = query.sessionId;
|
|
388
|
+
this.#runningSessions.set(sid, { name, prompt, model, query, lastMessageAt: new Date() });
|
|
401
389
|
|
|
402
|
-
log.debug({ name, sessionId }, "
|
|
390
|
+
log.debug({ name, sessionId: sid }, "Background session registered");
|
|
403
391
|
|
|
404
392
|
query.result.then(
|
|
405
393
|
({ value: response }) => {
|
|
406
|
-
if (!this.#
|
|
407
|
-
this.#
|
|
408
|
-
log.debug({ name }, "
|
|
409
|
-
this.#queue.push({ type: "background-agent-result", name, response
|
|
394
|
+
if (!this.#runningSessions.has(sid)) return;
|
|
395
|
+
this.#runningSessions.delete(sid);
|
|
396
|
+
log.debug({ name, message: response.message }, "Background session finished");
|
|
397
|
+
this.#queue.push({ type: "background-agent-result", name, response });
|
|
410
398
|
},
|
|
411
399
|
(err) => {
|
|
412
|
-
if (!this.#
|
|
413
|
-
this.#
|
|
414
|
-
log.error({ name, err }, "
|
|
415
|
-
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "
|
|
400
|
+
if (!this.#runningSessions.has(sid)) return;
|
|
401
|
+
this.#runningSessions.delete(sid);
|
|
402
|
+
log.error({ name, err }, "Background session failed");
|
|
403
|
+
this.#queue.push({ type: "background-agent-result", name, response: { action: "send", message: `[Error] ${err}`, actionReason: "bg-failed" } });
|
|
416
404
|
},
|
|
417
405
|
);
|
|
418
406
|
}
|
package/src/prompts.test.ts
CHANGED
|
@@ -10,7 +10,7 @@ describe("SYSTEM_PROMPT", () => {
|
|
|
10
10
|
expect(SYSTEM_PROMPT).toContain("Cron");
|
|
11
11
|
expect(SYSTEM_PROMPT).toContain("Buttons");
|
|
12
12
|
expect(SYSTEM_PROMPT).toContain("Files");
|
|
13
|
-
expect(SYSTEM_PROMPT).toContain("
|
|
13
|
+
expect(SYSTEM_PROMPT).toContain("Session routing");
|
|
14
14
|
});
|
|
15
15
|
|
|
16
16
|
it("contains HTML formatting instructions", () => {
|
package/src/prompts.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
export const MAIN_TIMEOUT = 60_000;
|
|
2
|
-
export const CRON_TIMEOUT = 300_000;
|
|
3
|
-
export const BG_TIMEOUT = 1_800_000;
|
|
4
|
-
|
|
5
|
-
const fmtMin = (ms: number) => {
|
|
6
|
-
const m = ms / 60_000;
|
|
7
|
-
return `${m} minute${m > 1 ? "s" : ""}`;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
1
|
export const SYSTEM_PROMPT = `\
|
|
11
2
|
AI assistant running in macroclaw, an autonomous agent platform. \
|
|
12
3
|
Persistent workspace at cwd with config, memory, skills. \
|
|
@@ -28,21 +19,24 @@ Context tags: messages may be prefixed with [Context: <type>]. Types:
|
|
|
28
19
|
- cron/<name> — automated scheduled task. Prefer action="silent" when nothing noteworthy.
|
|
29
20
|
- button-click — user tapped an inline keyboard button.
|
|
30
21
|
- background-result/<name> — output from a background agent you spawned. Decide whether to relay or handle silently.
|
|
31
|
-
- background-agent/<name> — you are a background agent. Complete task, return result.
|
|
22
|
+
- background-agent/<name> — you are a background agent. Complete task, return result.
|
|
23
|
+
- previous task "<prompt>" moved to background — a long-running task was demoted. Mention briefly if relevant.
|
|
32
24
|
|
|
33
25
|
Background agents: spawn alongside any response via backgroundAgents array:
|
|
34
26
|
backgroundAgents: [{ name: "label", prompt: "task", model: "haiku" }]
|
|
35
|
-
Each runs in same workspace,
|
|
27
|
+
Each runs in same workspace, forked session. Result fed back as [Context: background-result/<name>].
|
|
36
28
|
Models: haiku (fast/cheap), sonnet (balanced, default), opus (complex reasoning).
|
|
37
|
-
User can spawn directly with
|
|
29
|
+
User can spawn directly with /bg command. Use for long-running tasks that shouldn't block.
|
|
30
|
+
|
|
31
|
+
Session routing: if a new message arrives while your session is busy for over 1 minute, \
|
|
32
|
+
the running task is automatically moved to background and a new session is forked. \
|
|
33
|
+
You may see a [Context: previous task "..." moved to background] prefix when this happens.
|
|
38
34
|
|
|
39
35
|
Files: attachments listed as [File: /path] prefixes. Read/view at those paths. \
|
|
40
36
|
Send files via files array (absolute paths). Images (.png/.jpg/.jpeg/.gif/.webp) as photos, rest as documents. 50MB limit.
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
Cron: jobs in data/schedule.json (hot-reloaded). Use "silent" when check finds nothing new, "send" when noteworthy.
|
|
38
|
+
Cron: jobs in data/schedule.json (hot-reloaded). Cron jobs always run as background sessions. \
|
|
39
|
+
Use "silent" when check finds nothing new, "send" when noteworthy.
|
|
46
40
|
|
|
47
41
|
MessageButtons: include a buttons field (flat array of label strings) to attach inline buttons below your message. \
|
|
48
42
|
Each button gets its own row. Max 27 characters per label — if options need more detail, describe them in the message and use short labels on buttons.`;
|